Author SHA1 Message Date
Muse d4d259fa58 probe ws: frame cadence follows probe_fast_interval (V2.0.2)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 18:15:05 +08:00
Muse 9caa4e0cf9 install-agent: RELEASE_BASE -> V2.0.1
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
2026-10-23 16:13:25 +08:00
Rogue 29de9761c8 V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00
Muse c2f1840535 Merge feat/singbox-b5 (test contract update)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 02:47:01 +00:00
Muse 06de0e8384 B5: update stats plumbing test to the corrected registration contract 2026-10-23 02:47:01 +00:00
Muse 0a6e9b7a7d Merge feat/singbox-b5: ITEM6 B5 switchover + V2.0.0
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
Sing-box becomes the only runtime payload (master serves
singbox_config only; agents switch on its arrival, flush and
retire xray, roll back to xray on activation failure). Plus the
two rehearsal-caught accounting fixes: stats registration needs
all three name lists (recording gate enforces the switches), and
fragment tags now equal panel row tags so inbound totals land.
2026-10-23 02:43:58 +00:00
Muse 7e15bfbf8e Drop accidentally committed frontend/node_modules symlink (build env artifact) 2026-10-23 02:43:58 +00:00
Muse 6de22ae2e3 Remove throwaway live-stats probe test (rehearsal scaffolding) 2026-10-23 02:15:40 +00:00
Muse 05c164dc8f B5 rehearsal fix: fragment tag = panel row tag
The rehearsal's inbound-level totals never moved: the agent
reports sing-box tags (inbound-<port>) while the master's agent
traffic handler matches inbound rows by their panel tag — which
the B1 model renamed to the n<node>-in- scheme, so the lookup
silently matched nothing (per-user totals, keyed by email, were
unaffected; production on V1 keeps the old tag form and is not
hit until the V2 migration itself).

The converter now takes the row's panel tag as the fragment tag
(migrator passes it through), making one identity serve the DB
row, the generated config, core stats and traffic reports. Bare
fixtures without a panel tag keep the historical form.
2026-10-23 02:15:36 +00:00
Muse c610ab88ea B5 rehearsal fix: sing-box stats registration needs all three lists
The B5 full-stack rehearsal caught a silent accounting killer:
with the production switch set (inbound on, outbound off) the
generated v2ray_api stats block omitted the outbounds list, and
sing-box then registers NO counters at all — QueryStats answered
zero entries while real traffic flowed (A/B against the real core:
adding the list produced full user/inbound/outbound counters).

- builder: whenever the stats block is generated, emit inbounds,
  users and outbounds together; the gates now only decide whether
  the block exists
- master: enforce the inbound category gate at recording time in
  the agent traffic report (the whole-network accounting switch
  semantics: off period is never counted); onlines stay ungated
- golden gate test updated to the corrected contract
2026-10-23 01:57:52 +00:00
Muse 4a532e8c5a B5 switchover: sing-box is the only runtime payload
- master getConfig serves singbox_config only; xray payload retired
  (build failure is a hard 500, agent keeps its current state)
- agent: singbox_config arrival is the switch signal — flush xray
  tail traffic, stop xray, activate sing-box; activation failure
  restarts xray from the last payload and retries next poll; a
  legacy master (no sing-box keys) keeps the xray path untouched
- sing-box log rotation: 8 MiB copy-truncate with one .1 kept,
  checked on the traffic cycle
- install-agent.sh: xray/geo downloads and checks removed, agent
  binary now from the V2.0.0 release, self-check is sing-box
  binary + check on the live config
- display version default 2.0.0 (master V2.0.0 / agent A2.0.0)
2026-10-23 01:24:43 +00:00
Muse 3e2829e49d test(probe): give the contract-test theme a manifest (B5 store lists manifest-less dirs as uninstalled; fixture predates the store)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
2026-10-23 01:00:15 +00:00
Muse 19bf6fbc22 merge fix/vitest-reds: clear standing i18n/sub-port vitest reds (item 10)
# Conflicts:
#	internal/web/translation/ar-EG.json
#	internal/web/translation/en-US.json
#	internal/web/translation/es-ES.json
#	internal/web/translation/fa-IR.json
#	internal/web/translation/id-ID.json
#	internal/web/translation/ja-JP.json
#	internal/web/translation/pt-BR.json
#	internal/web/translation/ru-RU.json
#	internal/web/translation/tr-TR.json
#	internal/web/translation/uk-UA.json
#	internal/web/translation/vi-VN.json
#	internal/web/translation/zh-CN.json
#	internal/web/translation/zh-TW.json
2026-10-23 00:41:16 +00:00
Muse 460b314786 merge fix/leftover-tails: per-client link host, placeholder sanitizing, baseURL guard 2026-10-23 00:39:48 +00:00
Muse 3a02211555 merge fix/settings-trim: dead settings, upstream remnants, JSON/Clash subscriptions removed
# Conflicts:
#	frontend/src/models/setting.ts
#	frontend/src/pages/settings/GeneralTab.tsx
#	frontend/src/pages/settings/SettingsPage.tsx
#	frontend/src/pages/settings/SubscriptionBalancersTab.tsx
#	frontend/src/pages/xray/outbounds/OutboundsTab.css
#	frontend/src/schemas/setting.ts
2026-10-23 00:39:44 +00:00
Muse 268e6596bc merge feat/probe-b6: item 7 B1-B6 (probe collector, history, contract API, ping, themes, notify)
# Conflicts:
#	internal/agent/agent.go
2026-10-23 00:37:55 +00:00
Muse 7df7b530d3 merge feat/singbox-b4: item 6 B0-B4 (sing-box model, generation, agent runtime, frontend reshaping) 2026-10-23 00:37:12 +00:00
Muse 9e2807d3ec B6: coverage probeAt is unix ms — render it as such 2026-10-23 00:12:08 +00:00
Muse e43bd68986 B6 fixes from first E2E contact: PUT /probe/notify answers the fresh view; frontend notifyLogin is boolean and secret flags use the notify-prefixed wire names 2026-10-23 00:09:35 +00:00
Muse b889526b0d B6 frontend: notification settings + cf-probe retirement checklist on the public-page tab; probeNotify i18n x13 2026-10-22 23:56:27 +00:00
Muse 031b422365 B6 backend: probe notification engine — hub-faithful sweep (offline/recovery with flap discipline, traffic steps, expiry digest + auto-renewal, login), Telegram via the panel bot + webhook channel, notify settings/test/coverage API, login hook 2026-10-22 23:50:07 +00:00
Muse 277838d7ca ITEM6 B4: frontend reshaping — xray page retired, stats switches to settings, template read paths closed
- Delete the xray settings page (routes/menu/title), geodata UI/service,
  XraySettingController/Service and routing-sync write paths; the stored
  xrayTemplateConfig row loses its last readers (GetXrayConfigTemplate now
  returns the embedded factory template; B1 already deleted the row).
- Settings GeneralTab gains the four trafficStatsSwitches switches
  (settings.trafficStatsSwitches, B1) with the dual warning copy; entity
  AllSetting carries the key end to end (TestTrafficStatsSwitchesPlumbing).
- Tag-directory consumers (panelOutbound, mtproto egress, node/host
  outbound pickers) move from POST /xray/ to GET /server/outboundDirectory.
- Shared Mux/Sockopt forms and balancer helpers relocated to lib/xray;
  converter preserves sniffing ips/domains exclusions into the panel
  sidecar instead of dropping them (B2 gap closed by retention).
- Freedom final-rules DB fix seeders retired; getDefaultJsonConfig
  endpoint removed (no in-repo consumer); locales pruned to referenced
  keys (pages.xray 331 -> 68) plus the five new settings keys.
2026-10-22 23:40:54 +00:00
Muse f646212df2 B5: track the vendored fallback theme's dist/ (gitignore exception — it is embedded source, not build output) 2026-10-22 23:20:05 +00:00
Muse 2ee20b9691 B5 frontend: public-page settings tab — theme store UI, chunked upload install, config editor; 28 i18n keys x13 locales 2026-10-22 23:19:35 +00:00
Muse 000731c4e0 B5 tests: theme store matrix — refusals, replace, chunks, icons, repo parsing, dot-prefixed packages 2026-10-22 23:19:34 +00:00
Muse 2558efd9fb B5: theme admin API, hosting chain hookup, session cookie root path
Admin endpoints under /panel/api/probe/* (list, preview, chunk
upload, repo install, update, select, delete, public settings).
saveThemeConfig gate aligned to hub: selectable, not disk-installed.
Session cookie Path=/ so the root public surface (theme config save,
/api/me authed) can see the operator session under a non-root panel
base path — with basePath-scoped cookies it was unreachable (401).
2026-10-22 23:19:34 +00:00
Muse 7ad65652ec B5: theme store — hub-faithful install pipeline, GitHub install, embedded fallback theme
Install/remove/list/select with hub limits and refusal texts, chunked
upload receiver, site icons, public-surface settings; vendored
upstream default theme as the embedded fallback (MIT, stqfdyr).
The '.' root dir entry of ./-prefixed packages (guboysky LuminaPlus)
is the package root, not a path escape.
2026-10-22 23:19:26 +00:00
Muse 1762e54d5f ITEM6 B3: agent sing-box runtime (parallel path)
Agent consumes singbox_config alongside xray: candidate->check->
flush->swap activation with .previous rollback, wire-level v2ray_api
stats client with NexusGate epoch discipline (mid-query flip voids
the round, new epoch counts in full, agent-first snapshot baselines),
access-log online tracker (authenticated-connection lines, ANSI-safe,
merged with snapshot-active users), per-core traffic baselines with
the sing-box one carrying its epoch. install-agent.sh installs the
self-built sing-box v1.13.21 next to xray (xray/geo removal deferred
to the B5 switchover). Env-gated real-core E2E covers vless-reality,
ss2022, mixed, trojan and the forwarding leg end to end; hysteria2 is
probe-gated (sandbox blocks UDP replies) and stays a B5 pilot item.
2026-10-22 22:48:12 +00:00
Muse 1763f94a5f B4 frontend: probe task management modal on the nodes page
- ProbeTasksModal: self-loading/self-saving surface (B3 probe-meta
  pattern), tasks grouped by region label (ungrouped last), editor
  with name/region/target/interval/auto-join + agent-node multi
  select snapshotting baseNodeIds for the base-diff save; delete
  with confirm. Toolbar entry on NodeList (RadarChart icon), wired
  in NodesPage; NodeList call sites in two component tests updated
  for the new required prop.
- i18n pages.nodes.probeTasks.* (25 keys) in all 13 locales: zh-CN
  translated, en-US English, others English placeholder per repo
  convention; insertion verified pure-additive (351 insertions,
  0 deletions) and the i18n dead-key failures are byte-identical
  to the B3 baseline.
- tsc clean; oxlint 0/0 (baseline parity); oxfmt clean on touched
  files (3 pre-existing drift files untouched).
2026-10-22 22:20:23 +00:00
Muse 0d1607c358 B4 tests: probe store, agent prober, controller + wire
Baseline note: TestMetricHistoryMinuteFold fails identically on
the pristine B3 tip in the same wall-clock window (minute-parity
sensitive seed), passes in others — pre-existing, untouched path.
2026-10-22 22:09:11 +00:00
Muse e03ceaa41f B4: probe task store, panel API, heartbeat ingestion, agent prober
- probe package: probe_ping_task/probe_ping_node tables; task CRUD
  with hub save semantics (id high-water, base-diff assignment,
  64-per-agent crowding refusal, auto_join cap); filing with
  assignment-gated insert; PingHistory weighted-median chart read
  over minute+hour tiers; auto-join on first Record (ensureRows).
- controller: /panel/api/agent/probe-tasks* management endpoints
  (node id space, hub validation chain verbatim); series=ping now
  serves real rows from probe.PingHistory; heartbeat ingests
  probeResults (missing latency skipped, batch capped); config
  response carries probe_tasks (always present, empty = clear).
- agent: per-task probe loops (immediate first probe, ticker,
  900ms resolve/dial budgets, <=3 addresses, resolve overrun = no
  sample), result buffer drained on heartbeat and requeued on
  failure; task set applied before the config-hash early return.
2026-10-22 21:57:23 +00:00
MuseandMuse Spark 741cd905ff ITEM6 B2: sing-box generation side (parallel path)
- SingboxService builds the full sing-box config from the new model:
  stored fragments re-projected against the identity layer (converter
  projections factored into internal/singbox/project.go, shared by
  converter and generator), agent singbox_outbounds/routing_rules
  override, forwarding compiled to sing-box outbounds+route rules,
  default private-CIDR reject rule (geoip:private replacement),
  tun dns landing in the config dns block (closes the B1 'B2' pending).
- trafficStatsSwitches wiring: experimental.v2ray_api.stats lists are
  generated per category only when both direction switches are on;
  all-off yields no experimental block (no backfill, per the switches'
  documented semantics).
- Agent /config endpoint additionally serves singbox_config +
  singbox_config_hash; xray payload untouched, sing-box build failures
  log-and-omit.
- SubService.GetLinkFromModel regenerates share links from identity +
  singbox_config/singbox_preserved only (legacy generators reused over
  rebuilt settings/stream views). Golden fixtures 26/26 byte-equal;
  production backup DB: 107/107 links equal (55 byte-equal, 52 equal
  modulo the legacy generator's own random Reality sni/sid pick),
  generated config passes sing-box check.
- Converter preservation amendments (panel-side, for blocked/discarded
  material link generation still needs): blockedTls for xhttp/kcp,
  verbatim streamNetwork/streamSecurity, vlessEncryption presence,
  ssPassword for removed ciphers, wireguardDns, socksAuth/socksAccounts
  provenance for exact mixed/socks user refresh.

Co-Authored-By: Muse Spark <noreply@muse.dev>
2026-10-22 21:42:40 +00:00
Muse 540c05b0a5 feat(probe): public contract API + theme hosting + meta editing (item 7 B3) 2026-10-22 21:39:06 +00:00
Muse d5dab0229c merge fix/v102-agent-surfaces: agent placeholder host in inbound export links + agent online list wiped by node traffic sync (V1.0.2)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-22 20:16:11 +00:00
Muse 0e9c2031fb fix(v1.0.2): agent placeholder in inbound export links + sync job wiping agent onlines
Fix 1: InboundsPage hostOverrideFor read NodeView.address raw; agent
nodes store the literal placeholder "agent" there, so exported inbound
links came out as vless://...@agent:<port>. Route the pick through the
new shareHostForNode (lib/xray/inbound-link): resolved ipv4/ipv6 first,
raw address only as a non-placeholder fallback; the placeholder can
never reach a link and the fallback chain continues when an agent has
no telemetry yet. Traditional nodes (address only) are unchanged.

Fix 2: agent nodes showed no online users on the nodes page. The
attribution keys were NOT misaligned (write effectiveNodeKey ==
read effectiveNodeGuid == "node:{id}" for guid-less agent nodes,
pinned by TestAgentNodeOnlineEmailsInNodeList). The node traffic sync
job (every 5s) pull-syncs every enabled+online node as if it were a
panel; agent nodes have no panel API, so FetchTrafficSnapshot always
failed and its failure branch ClearNodeOnlineClients wiped the online
tree the agent had just pushed via SetAgentOnlineClients. syncOne now
skips agent-managed nodes (push-only), mirroring the heartbeat job.
Pinned by TestSyncOne_AgentNodeKeepsReportedOnlines (fails pre-fix
with byGuid emptied after one tick, passes post-fix).
2026-10-22 20:11:08 +00:00
Rogue 3e052c42b8 feat(probe): history store + whole-machine traffic ledger in probe.db (item 7 B2)
New internal/web/service/probe package keeps probe history in its own
probe.db beside the panel DB (single connection, WAL), so history
writes never contend with the panel write lock.

- Minute rows per hub semantics: a held-reading rhythm books traffic
  against each reading own arrival; minute buckets merge (means,
  rounded int means, peaks) and the stored net rate derives from the
  kernel counters between row marks, falling back to agent rates at
  epoch breaks.
- probe_traffic ledger: accumulate is a line port of hub traffic.rs
  (baseline alignment, same-boot shrink books 0, epoch change books 0,
  unseen-boot exception books the whole counters-since-boot measured
  against the master last-seen stamp, per-direction period resets,
  saturating adds); reads gate stale day/month stamps to zero.
- probe_meta rows are ensured for every reporting agent (B3 edits
  them in place); probe_ping_* tables are created now, written by B4.
- Hourly roll_up behind a watermark (hour-aligned, late grace,
  pre-retention hours skipped unfoldered) and watermark-guarded
  chunked prune (minute tier 7 days or the retention window when
  shorter; hourly tier to retention_days, default 7, cap 365).
- Wiring: heartbeat files probe samples after the snapshot update
  (last-seen captured before touch); housekeeping hangs off the
  existing robfig/cron in web.startTask (@hourly + one pass a minute
  after startup, mirroring the hub boot pass); Server.stop books held
  readings. Every failure path logs and degrades - a broken probe.db
  never fails a heartbeat or the panel.
2026-10-22 19:43:59 +00:00
Muse 39ad8b8a2e ITEM6 B1: sing-box data model + one-shot migration
New stored model (legacy columns untouched, read paths unchanged
until B2):
- inbounds.singbox_config (converted sing-box inbound/endpoint
  fragment JSON; empty = blocked or external-runtime) and
  inbounds.singbox_preserved (unmapped items verbatim + panel-side
  material + notes)
- agent_nodes.singbox_outbounds / singbox_routing_rules

Converter (internal/singbox, pure functions, ITEM6-CONVERSION-SPEC):
all panel protocols mapped; unmapped constructs are preserved and
registered, never dropped. B0 golden fixtures: 26/26 semantic match
(fragment + unmapped concept/pending sets + disposition).

One-shot migrator (internal/database, InitDB hook after initModels):
marker-gated and idempotent; SQLite file backup before any write
(failure refuses migration/startup); stats switches migrate out of
the template BEFORE the xrayTemplateConfig row is discarded
(SCOPE 4.3.1 order); identity layer is the only client truth with
spec 7-1 mirror reconciliation (adopt/fill/conflict all reported);
fallbacks merged from table + embedded settings, deduped; agent
overrides converted with per-entry issues; everything in one
transaction, marker written last; JSON report written next to the
DB. Malformed rows/templates are hard failures (rollback, old DB
untouched).

settings.trafficStatsSwitches added with factory defaults
(inbound on, outbound off) in defaultValueMap.

Validation: synthetic agent-era DB (mirror drift, fallbacks, xhttp,
agent geo rule, non-default stats) converts as specified; old
production backup (25 inbounds) migrates with credentials, Reality
pbk derivation, traffic counters and reset cycles byte-identical;
idempotent re-run is a no-op; touched packages show zero new test
failures vs the 9-red baseline.
2026-10-22 19:32:15 +00:00
Muse 1bd63e6625 settings trim: remove dead settings, upstream remnants, JSON/Clash subscriptions
Per SETTINGS-AUDIT (all three tiers approved 2026-10-23):

Dead settings:
- panelOutbound, ipLimitAllowlist (+ orphan parser job), outboundDownThreshold
- tg fake events outbound.down/outbound.up/xray.crash (no publisher in lite)
- phantom routing sub-tabs (Happ/Clash/Incy) and subJsonRules direct-rules tab
  (no backend keys); subHideSettings moved to the subscription info tab first

Upstream remnants:
- ldap* / smtp* model+schema keys, EmailNotifications orphan component
- twoFactorEnable/twoFactorToken + login-page probe (no backend handler)
- externalTrafficInform*

Newly approved removals:
- JSON subscription family (producer, routes, UA auto-detect, mux/finalmask/
  observatory) and Clash subscription family (producer, routes, UA detect)
- SubBalancer (table, service, controller, settings tab)
- subThemeDir (subscription page falls back to the built-in template)
- datepicker jalalian option (gregorian only; Persian picker removed)

JSON/Clash endpoints now 404; link-format output pinned by regression tests.
Guarded keys untouched: webListen/webPort/webBasePath/certs, tg* chain,
expireDiff/trafficDiff/tgBotProxy, subHideSettings, pageSize/tgLang.
2026-10-22 19:21:07 +00:00
Muse a47e64f70a test(i18n,sub): clear the three standing vitest reds (item 10)
- i18n dead keys: drop 13 keys with no reference left (pages.xray.routeTester*
  from the P9 feature removal, tgbot.online superseded by tgbot.messages.online,
  tgbot.commands.restartSuccess/restartFailed from the rewritten restart handler)
- i18n key sets: add the P9 forwarding block (20 keys) to the 11 locales that
  missed it, as English placeholders per repo convention; all 13 locales now
  carry identical key sets
- subscription port test: the case predated P7 — clearing the port field is the
  explicit subPort 0 (no port in generated links) opt-in via onNumber onClear,
  so assert updateSetting is called with { subPort: 0 } instead of never called

No component code, UI behavior, or styles touched.
2026-10-22 18:23:22 +00:00
Muse 0a973bc95c feat(probe): port monitor-probe collector into agent, persist latest snapshot (item 7 B1) 2026-10-22 18:22:56 +00:00
Muse 9843a7ce4e ITEM6 B0: add census.sql for production stock census (HW-SG run) 2026-10-22 18:17:18 +00:00
Muse 750a270955 ITEM6 B0: golden fixtures + fixture validation skeleton for sing-box migration
26 cases (protocol x representative transport): current-shape input,
expected sing-box fragment, and share-link baseline generated by running
current main@8e89960 GetLink. Blocked/partial cases (xhttp, kcp,
fallbacks, finalmask, vlessenc, removed SS ciphers, tunnel) record their
unmapped concepts under _b0 for the Q1 decision instead of fabricating
output. Link-route baselines only: JSON/Clash subscription families are
being removed wholesale per the 2026-10-23 decision, Q2 closed.
2026-10-22 18:14:58 +00:00
Muse 3413ccca9f fix three V1.0.1 leftover tails
1. per-client links (LinkProvider.LinksForClient) now apply the
   inbound's managed Host records with the same precedence as
   subscriptions and export-all (host endpoints after fallback
   projection); zero-host inbounds keep the legacy output.
2. agent node writes no longer store the form placeholder
   address='agent', port=2053: CreateFromRequest/UpdateFromRequest
   sanitize it to the node's resolved agent address (update) or
   empty (create / unreported), port 0; normalize gains a lenient
   endpoint mode for agent writes only.
3. runtime Remote.baseURL refuses the legacy 'agent' placeholder
   with a clear no-panel-endpoint error instead of splicing it into
   a dialable https://agent:2053 URL.
2026-10-22 17:55:19 +00:00
Muse 8e89960474 merge wiring: route agent address lookups through ResolveAgentAddresses (V1.0.1)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 00:34:33 +08:00
Muse afb5456b8f merge fix/agent-public-addr: master-observed public IP resolution (V1.0.1) 2026-10-23 00:22:58 +08:00
Muse 36acc4c2b1 merge fix/agent-link-address: agent inbounds export telemetry address (V1.0.1)
# Conflicts:
#	internal/web/service/inbound.go
2026-10-23 00:22:36 +08:00
Muse 7f098c6c4e merge fix/online-lists: agent inbound attribution + node page email lists (V1.0.1) 2026-10-23 00:21:04 +08:00
Muse 942b220f80 merge fix/token-backfill-null: legacy NULL token_plain backfill (V1.0.1) 2026-10-23 00:21:04 +08:00
Muse 7d9a38490d fix(sub): agent-hosted inbounds export the agent's telemetry address, not the 'agent' placeholder
Agent nodes store the create form's placeholder in nodes.address
('agent', port 2053; NodeFormModal.buildPayload). The export fallback
chain treated it as a real node address, so every host-less inbound on
an agent node produced links like vless://...@agent:31324.

SubService now loads agent_nodes bindings alongside nodes (latest
last_seen per node, same rule as the node list) and resolves an
agent-managed node's export address through the single convergence
point agentNodeAddress(nodeID): telemetry IPv4, then IPv6, then ""
so the chain continues to listen/public/request host and can never
emit the placeholder. Host-bound inbounds and traditional nodes are
untouched. GetInboundOptions applies the same substitution via
agentAdvertisedAddresses so the frontend share/QR/WireGuard feed
never sees the placeholder either.
2026-10-22 15:35:21 +00:00
Muse 3df652a67d fix(inbounds): slim list stamps agent-hosted inbounds with the agent node's key
Agent inbounds defined centrally store an empty originNodeGuid, and
inbounds re-homed from a panel node keep the previous host's GUID, so
the inbounds page looked the agent's online set up under a key it is
never filed under. GetInboundsSlim now annotates agent-hosted
inbounds at read time with the node's effective attribution key
(the key SetAgentOnlineClients files onlines under).
2026-10-22 15:33:51 +00:00
Muse c91fb454a8 fix(nodes): per-category client email lists behind the node count chips
NodeView carried only the category counts, so the nodes page chips
could hover to nothing but the category title. GetAll/recountByGuid
now emit online/active/disabled/depleted email lists from the same
bucketing pass as the counts (statuses gain the client email;
the online list is the counted set), and the chips pop the list
over with the inbounds page's popover pattern.
2026-10-22 15:33:46 +00:00
Muse c490bdcd76 fix(nodes): resolve agent public address from master-observed source IP
NAT'd agents (cloud 1:1 EIP) only ever report their private
interface address in heartbeat telemetry, so the node list showed
an unusable RFC1918 address. The master now records the source
address of every authenticated agent request (CF-Connecting-IP,
else first X-Forwarded-For hop, else RemoteAddr; change-only
writes) on the agent row as seen_ipv4/seen_ipv6, and the node
list resolves the displayed pair via service.ResolveAgentAddresses:
public telemetry wins, else a public observed source, else the
reported address as-is. CGNAT 100.64/10 observed addresses are
never promoted. Telemetry values are kept verbatim; resolution
is read-time only, and agents need no changes.
2026-10-22 15:25:08 +00:00
Muse 5c432205ad test(agent):回填回归测试——原生 SQL 造 NULL 老行走真实心跳+install 接口 2026-10-22 14:45:57 +00:00
Muse 8901267867 fix(agent):第 19 条 token 回填守卫兼容 SQL NULL 老行(token_plain IS NULL OR = '') 2026-10-22 14:45:55 +00:00
Muse e310abcba0 merge next-scripts: items 15/16/17/23 (install-agent mirror/reuse/self-check, openrc respawn)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-22 21:51:51 +08:00
Muse ced5e69de0 merge next-core: items 14/18/19/20/21/22 (L-UI V1.0.0/A1.0.0) 2026-10-22 21:51:50 +08:00
Muse a82d1e56fa style: 本批新代码 gofmt 对齐(config_test/agent_node) 2026-10-22 13:46:02 +00:00
Muse 7e7ab2ecbd feat(update): 发行 tag 查询支持 XUI_RELEASE_LATEST_URL 覆盖(镜像/沙箱冒充发行源) 2026-10-22 13:33:08 +00:00
Muse d46035708e feat(ui): 第22条 改名 L-UI + 第14/21条 + 第18/19/20条前端
- 第22条 L-UI:登录页品牌名、侧栏 brand-text(收起态 L)、抽屉品牌、
  usePageTitle 默认标题(HTML 标题)、13 个语言文件里用户可见串中的
  3X-UI 字样(更新说明×2、TG 版本行)一并改为 L-UI;仓库/二进制/
  服务名/路径/内部包名不动
- 第18条:formatPanelVersion 对 V/A 系显示版本原样返回(新增单测);
  PanelUpdateModal 与首页动作条改用 display* 字段判更新与显示
- 第14条:首页运行时间卡片删除 Xray 半边与分隔线,只留 OS
- 第21条:节点表列宽 操作190→150/已启用80→64/客户端180→150/
  延迟100→84/心跳120→96、地址列 330;agent 行 IPv4·IPv6 单行
  (浅点分隔、沿用可见性 class),传统行不变
- 第20条:agent 行云朵按钮与移动端菜单改用「更新 Agent」文案;
  NodesPage 对 agent 行用无渠道选择的简单确认(传统行不变);
  schemas 增 agentUpdateState/Error/Target
- 第19条:节点编辑页(agent)拉 /panel/api/agent/install/:id 展示
  完整安装命令(master 可编辑、含 token 时完整命令+复制按钮);
  无 token 时展示无 token 命令+agent.json 路径提示,不报错不空白
- i18n 新增 4 键(updateAgent/updateAgentConfirmContent/
  agentInstallCommand/agentTokenNotStored)补齐全部 13 个语言文件
  (zh-CN 中文、其余英文),tsc 干净、panel-version 测试全过、
  i18n 失败仍为既有 forwarding 键集问题(第10条)零新增
2026-10-22 13:30:57 +00:00
Muse 2596880327 feat(update): 第20条 主控面板一键自更新(Go 原生换名 + systemd 重启)
- PanelUpdateInfo 增 displayCurrentVersion/displayLatestVersion/
  displayUpdateAvailable:V 系按个位进位段比较,大于当前才算新版;
  原 currentVersion/latestVersion/updateAvailable 语义不动(节点页
  与传统节点继续用)
- 稳定渠道自更新改 Go 原生路径:查最新 tag→按架构下载发行资产→
  sha256 旁车校验→原子换名(旧二进制留 .bak)→写既有状态文件
  (与 update.sh 同形状,前端轮询协议不变)→脱离进程 systemctl
  restart 本单元(单元名 XUI_SERVICE_NAME 覆盖 / /proc/self/cgroup
  解析 / 回退 x-ui);dev 渠道保留 update.sh 原路径
- 仅 systemd 环境启用(/run/systemd/system 存在或
  XUI_SELFUPDATE_SYSTEMD=1 沙箱钩子),否则报错提示手动更新
- 单测:显示版本比较、cgroup 单元名解析、状态文件往返、systemd
  门拦截
2026-10-22 13:16:35 +00:00
Muse 2365eb138b feat(update): 第20条 agent 一键在线更新(意向→轮询→校验换名→自重启)
- 新增 internal/selfupdate:发行坐标(基址常量+env 覆盖)、Gitea
  latest tag 查询、sha256 旁车校验下载、原子换名(旧二进制留 .bak、
  失败可回滚),含单测
- 主控:UpdatePanels 对 agent 节点改为记录更新意向(最新 tag 的
  V 系→A 系目标;已达标报错不记意向);意向经现有 GET config 轮询
  捎带下发;心跳收 updateStatus 回报(failed/current 清意向留状态、
  过期 requested_at 忽略),遥测版本达标自动确认 success
- agent:识别意向→比自身 A 版本(已达标报 current 不下载)→按架构
  下载资产+sha256 校验→换名留 .bak→刷一次心跳→stopXray→
  syscall.Exec 自重启;exec 失败回滚 .bak 并报 failed;失败 10 分钟
  内不重下同一意向;旧版全程保持运行
- NodeView 增 agentUpdateState/Error/Target 供节点页可观测
- 单测:selfupdate 换名/校验、agent 换名重启/坏校验保旧版/已达标
  跳过/exec 失败回滚、主控意向记录/已达标跳过、config 捎带意向、
  心跳 failed 清意向、心跳新版本确认 success
2026-10-22 13:14:24 +00:00
Muse 27a5097756 feat(install-agent): 第23条 OpenRC 韧性对齐——supervise-daemon 守护替代 command_background
OpenRC 支原用 command_background 后台化,进程崩溃不自动拉起,与 systemd 支
Restart=on-failure 语义不对齐。生成的 /etc/init.d/x-ui-agent 改用
supervisor=supervise-daemon + respawn_delay=5(对齐 RestartSec=5)+
respawn_max=0/respawn_period=0(不限次,对齐持续重试);pidfile 与
output/error_log 约定原样保留,command/command_args 不动,幂等重跑时
init 脚本整文件重写、旧式 command_background 布局自动迁移。

第 15/16/17 条对 OpenRC 路径复核:下载基址、无参沿用、失败报错、收尾
self_check 与 rc-service status 存活断言均在公共段/现有分支内已达标,
未作改动(由新增 OpenRC fake-root 套件实证)。

验证:既有 systemd fake-root 套件 58/58 零回归;新增 OpenRC 套件
(run-tests-openrc.sh,O1–O5)51/51。改动点仅此一处(+7/-1)。
2026-10-22 13:12:57 +00:00
Muse f18aca9410 test(agent): 遥测 AgentVersion 断言对齐第18条 A 系口径 2026-10-22 13:12:14 +00:00
Muse 29b724dae1 feat(nodes): 第19条 agent token 可恢复保存 + 老节点回填 + 安装命令接口
- AgentNode 新增 TokenPlain(json:"-" 永不序列化):建节点/签发 token
  时一并存明文,用户已明确接受"可恢复保存换便利"的口径
- 回填:findAgent 在 token 通过哈希校验后,若该行尚无明文则把本次
  请求携带的 token 回填存下(带 token_plain='' 守卫的 UPDATE,
  已有明文绝不被请求覆盖;只有面板侧签发流程写新值);鉴权语义不变
- 新增管理端点 GET /panel/api/agent/install/:nodeId 返回
  {nodeId,name,token,hasToken},供编辑页重显完整安装命令;老节点
  未回填时 hasToken=false,前端展示无 token 命令 + 本机路径提示
- 单测覆盖:签发存明文 / 回填后接口可读出完整 token / 校验失败
  绝不回填 / 已有明文不被覆盖
2026-10-22 13:04:33 +00:00
Muse 1edb8f71da feat(version): 第18条 显示版本与内部版本解耦(V/A 双系)
- config 新增 displayVersion 注入变量(默认 1.0.0):GetDisplayVersion/
  GetMasterDisplayVersion(V)/GetAgentDisplayVersion(A),以后升版只改注入值
- ParseDisplayVersion/CompareDisplayVersion:三段数值比较,个位进位规则
  (1.0.9<1.1.0) 天然成立;不可解析(dev 标签/3.7.0-agent.1)返回 ok=false
- 显示位切换:dist 注入 X_UI_CUR_VER、-v、TG 报告 → V 系;
  agent 遥测 agentVersion → A 系;节点行 A 系原样显示、老 agent 沿用
  "agent " 前缀兼容
- 内部流不动:GetBaseVersion/GetPanelVersion 及其比较/订阅/远端状态
  消费方保持原样,传统节点更新判定与徽标行为不变
2026-10-22 13:01:50 +00:00
Muse 8900ef68fc feat(install-agent): 镜像下载(15)+无参沿用agent.json(16)+失败报错与收尾自检(17)
- 下载基址统一 RELEASE_BASE 指向 Gitea 发行 V1.0.0:agent 二进制(原在旧
  tag v3.7.0-agent.1)、双架构 xray zip、geoip/geosite 全部走自建镜像,
  不再依赖 GitHub CDN
- 未传 --master/--token/--name 时逐字段沿用本机 /etc/x-ui/agent.json,
  显式参数优先;日志打明沿用的节点名与主控,绝不打印 token;文件不可
  解析且必填仍缺则明确报错退出
- 每步下载/解压显式判失败(拆掉旧 && 链的静默吞错),下载验非空,前置
  检查 curl/unzip;收尾 self_check 真跑 xray version、验 geo 非空与
  agent 可执行,任一不过报错退出且不写配置不起服务
- fake-root 模拟 9 场景 58 断言全 PASS(报告 NEXT-SCRIPTS-REPORT.md)
2026-10-22 12:54:32 +00:00
Muse e5f39ed20b merge: p13-agent-telemetry (agent 遥测上报+节点页显示+地址纯文本)
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
2026-10-22 19:36:50 +08:00
Muse 487b074801 feat(nodes-ui): agent 行地址纯文本显示 IP+更新徽标防误亮(第13条)
地址列: agent 行渲染 ipv4/ipv6 纯文本(两行), 不再是面板 URL 链接;
传统行保持链接。面板版本列: agent 行不参与"有可用更新"判定
(版本比较对 "agent x.y" 字符串走不等回退会恒亮)。
2026-10-22 11:33:58 +00:00
Muse 3536d4174e feat(telemetry): agent 心跳遥测上报+节点列表覆写(第13条)
agent 每 60s 心跳捎带遥测: agentVersion/xrayVersion(按 mtime 缓存重读)/
uptime/最近一次主控请求 RTT/cpu%/mem%/公网 v4+v6(公网优先, ULA/私网兜底);
主控心跳处理按指针语义只存上报字段, 旧 agent 无遥测行为不变;
agent_nodes 加法迁移存遥测, 节点列表对 agent 行覆写真值,
panelVersion 显示 "agent <版本>", NodeView 新增 ipv4/ipv6。
2026-10-22 11:33:56 +00:00
Muse 84d7b85be7 merge: p12-agent-rename (agent 改名 xui-agent+脚本迁移)
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
2026-10-22 18:40:28 +08:00
Muse 81d89840e4 fix(agent): 流量上报间隔 60s 改为 10s(第9条)
传统节点由主控秒级轮询,agent 旧默认 60s 才推送一次,所有 agent
入站的流量/在线刷新慢一个数量级(用户真机实证,与具体节点无关)。
defaultTrafficInterval 改为 10s;心跳间隔 60s 不动;注册退避上限
(registrationBackoff 的 60s)与流量无关,不动。原注释称 traffic
piggyback 心跳 tick 与实现不符(实为独立 ticker),一并改正。
2026-10-22 10:25:22 +00:00
Muse ff1204ce2d fix(onlines): agent 在线名单按节点归因键存储(第12条修复A)
reportTraffic 旧实现把所有 agent 的在线名单写死存入 "agent" 键,
而入站页按 originNodeGuid / node:{id} 归因键查找,永远查不到:
agent 入站行永不亮在线,且所有 agent 节点被并进同一键。
改为 InboundService.SetAgentOnlineClients:按节点的 effectiveNodeKey
(有唯一 GUID 用 GUID、否则 node:{id})存入;空名单仍走
ClearNodeOnlineClients 清整棵子树。同步修正 online_store_test 中
断言旧键的用例注释与键名;新增两例服务层测试(双 agent 节点键互不
合并、空上报清空)。
2026-10-22 10:25:15 +00:00
Muse 58cdfe73b0 feat(agent): P12 改名 xui-agent——独立二进制文件+改名覆盖+存量迁移清理
install-agent.sh: AGENT_BIN 改 /usr/local/x-ui/xui-agent,下载走 .new 改名
覆盖(agent 自升级不撞 Text file busy),全程不写主控 x-ui 文件;新增
stop_legacy_agent 只精确匹配旧形态 'x-ui agent' 进程(主控裸 x-ui 无参数、
天然不误杀);生成的 systemd/OpenRC 服务改指 xui-agent。仓库 systemd/
x-ui-agent.service 与 openrc/x-ui-agent( procd ) 同步改指;README/docs 的
agent 手动部署段落位改 xui-agent。Go 代码无硬引用、零改动。
fake-root 模拟(systemd/OpenRC/存量重跑)23/23 PASS,见 P12-REPORT.md。
2026-10-22 09:46:46 +00:00
Muse ac39370879 fix(onlines): 恢复被 P9 掏空的在线状态存储(第11条)
根因: 1035d00 删本地核心时, inbound_node.go 的在线存储层被一并掏空
(GetOnlineClients 写死空/SetNodeOnlineTree 等空函数/SetRemoteTraffic
末尾喂树整块删除), 而节点轮询与 agent 上报两条到达路径完好, 数据
送达即被扔, 客户端/入站在线永远全零。

修复: 把原 xray.Process 上的在线存储(算法/锁/grace 20s 语义逐字
移植)落到 service 包单例 onlineStore(sharedOnlineStore, 对应原
currentXrayProcess 的单一持有语义); 恢复六个 wrapper + 新增
SetNodeActiveInboundTree; setRemoteTrafficLocked 补回 guidShared
与喂树块(原 f727d04 逻辑, process. 换 s.)。

偏离: 本地集在精简主控无本地核心喂养, 仅 mtproto job 折入(该 job
已不被调度, 路径保留无害); GetOnlineClients 对 nil 归一为 []。

测试: online_store_test 9 例(-race 全过: 并集/按GUID/替换/清空/
grace 边界/跨实例共享/并发); SetRemoteTraffic 集成 2 例(传统节点
GUID 树+active 树落库、旧节点 flat 回退); 全量 go test 串行复跑失败集合与基线 9 红逐字一致(零新增);
service 包单跑唯一红为基线既有的 TestGetXrayConfig 红, 其余全过。
2026-10-22 09:19:20 +00:00
Muse f569680182 merge: p93-agent-register (注册退避重试+脚本探测服务化+面板地址预填)
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
2026-10-22 04:55:19 +00:00
Muse 9adffd049c merge: p92-socks-clients (SK5 分配用户+记账+订阅 socks 链接) 2026-10-22 04:55:07 +00:00
Muse 8ca1a4647a feat(sub): emit socks:// links for clients on socks/mixed inbounds
GetLink had no socks-family case, so an attached client's
subscription contained no link for the inbound at all, and
getInboundsBySubId's protocol list omitted socks/mixed entirely, so
such a subscription 404'd when the SK5 inbound was the client's
only one. genSocksLink emits socks://user:pass@host:port with the
same credential pair the core config carries — user is the client
email, pass is the client password, both percent-encoded. The
inbound's own fixed account is an operator credential and is never
emitted.
2026-10-21 22:25:18 +00:00
Muse 0ed6d8a1cf fix(nodes): agent 主控地址预填 origin+基础路径,地址为空时不生成安装命令并提示 2026-10-21 22:05:09 +00:00
Muse dcfaf6ace6 feat(install-agent): 启动前探测主控注册接口(404/连不上即中止),改为 systemd/OpenRC 服务化安装带自启 2026-10-21 22:05:09 +00:00
Muse 7d03749246 feat(agent): 注册失败退避重试不停机,404 时日志提示检查 master_url 基础路径 2026-10-21 22:05:09 +00:00
Muse 5efaaef2c2 feat(xray): emit attached clients as accounts for socks/mixed inbounds
Socks-family inbounds authenticate against settings.accounts, so the
clients attached to them never reached the core: the generated config
carried the settings' "clients" mirror (ignored by xray for these
protocols, passwords and all) and only the inbound's own fixed
account. Emit one account per enabled attached client instead —
user = client email, pass = client password — appended after the
inbound's stored accounts, which keep their order and win name
collisions (managed-forwarding resolution reads the fixed account
from the stored settings, untouched). Drop the clients mirror from
the generated settings, and force auth=password when clients exist,
since accounts are inert under noauth.

Accounting needs no new plumbing: xray-core 26.6.27 reports these
accounts under user>>><account user> counters (probe-verified with
two accounts on both mixed and socks), and the agent snapshot ->
report -> AddTraffic chain already keys client traffic by that exact
string, so making the account name the client email aligns booking
end to end. Clientless socks-family inbounds pass through verbatim.
2026-10-21 22:04:49 +00:00
Muse bea7f4b2b6 feat(clients): allow attaching clients to socks/mixed inbounds
The client form, bulk-add form and the inbound-side attach entry all
filtered attachable inbounds by a protocol whitelist that lacked
socks/mixed (inherited from upstream), so SK5 inbounds could never be
picked as a client's associated inbound even though the backend
accepts the association. Add 'socks' and 'mixed' to all three gates.
The client record's existing password field is the socks credential;
no new credential shape is introduced.
2026-10-21 22:01:30 +00:00
Muse 051a74be80 feat(inbounds): forwarding chip on list rows
Rows whose stored ForwardingSpec is enabled get a volcano chip after
the protocol tags: '转发 → <target remark>' for managed landings
(resolved against the full row set), '转发 → <address>:<port>' for
manual endpoints, '转发 → SK5' when the target row is gone. Text is
ellipsised at 180px with a tooltip so narrow rows never overflow.
Data rides the slim list's existing forwarding field — no API change.
2026-10-21 21:01:45 +00:00
Muse d867b15f4a fix(inbounds): forwarding echo lost in DBInbound — add the missing field
The edit modal seeds from the list row wrapped in DBInbound, whose
cloneProps only copies keys the instance already owns. DBInbound had
no forwarding field, so the stored ForwardingSpec JSON was silently
dropped on every row wrap and rawInboundToFormValues fell back to
direct — saved forwarding compiled and worked, but never echoed back
into the form. Backend/API carried the field correctly all along.

Regression test covers managed/manual/direct through the exact
row -> DBInbound -> form-values chain.
2026-10-21 20:59:36 +00:00
Muse 4da8d58307 fix(install-agent): pkill 无匹配时返回 1 会被 set -e 掐死在启动前,加 || true 2026-10-22 04:35:52 +08:00
Muse 6a880aed09 P9(4b): PANEL-LITE-SPEC 同步本地核心整条路径删除与 MTProto/AmneziaWG 退役 2026-10-21 19:30:53 +00:00
Muse 1035d00fe4 精简版P9(4b): 删除本地核心整条路径(进程管理/buildXrayConfig本地分支/local-only注入/本地核心接口与任务)
- service/xray.go 1741->573行: 只剩 agent 配置构建(GetXrayConfigForAgent/buildAgentXrayConfig=模板+节点入站+egress+转发编译); 删进程生命周期/状态机/RestartXray/StopXray/SetToNeedRestart/GetXrayTraffic/GetOnlineUsers/balancer/TestRoute 与 injectPanelEgress/injectNodeEgresses/injectMtproto/injectAmneziawg/mergeSubscriptionOutbounds
- controller: 43处 SetToNeedRestart 与各 RestartXray 调用清除; 删 balancerStatus/balancerOverride/routeTest/getXrayResult 路由与handler(纯本地核心操作); 前端删 RouteTester 页签与组件
- server service: GetConfigJson 改模板直读; 状态Xray块恒 stopped/空版本; Stop/RestartXrayService 改 no-op(DB导入/恢复流程保留形状)
- setting service: PanelEgressProxyURL/NodeEgressProxyURL 恒直连(桥在本地核心内,已不存在); NodeService.withOutboundBridge 恒直连回退
- runtime: Local 改为全 no-op 桩(DB即真源); NewManager() 去 LocalDeps(APIPort/SetNeedRestart)
- job: 删 XrayTrafficJob/CheckXrayRunningJob/CheckClientIpJob 三文件(共享声明 EventBus/IPWithTimestamp/clientStatsSnapshotMaxClients 迁 shared.go); web.go 不再调度 MtprotoJob/AmneziaWGJob, 删 OnCrash 接线与 stop() 的 sidecar StopAll
- MTProto/AmneziaWG 下线: AddInbound/UpdateInbound 对两协议直接拒绝(存量行保留可删); TG bot 的 restart 命令与在线查询按无本地核心收口
- 测试: GetXrayConfig 系列移植到 agent 构建(finalmask/clients/wireguard/scale); 删退役行为测试(lifecycle/restart/mtproto本地应用/client-ip job/inject系列); 共享测试helper迁 mtproto_test_helpers_test.go 与 job/integration_helpers_test.go
- 既有红(非本轮引入,已在 f6d6520 复现): TestGetXrayConfig_EnabledClientsStillEmitted、TestRouteRegistryContract(P6删 endpoints.ts 遗留)
2026-10-21 18:43:55 +00:00
Muse f6d652019d P9(3): per-inbound forwarding module (amo-style form, dynamic egress compile)
Design as decided 2026-10-21 (replaces the raw-JSON egress editor idea):
the forwarding form lives on the inbound, and the entry agent's egress
entries are compiled from it on every config pull — never snapshotted.

- model: Inbound.Forwarding (JSON text) via AutoMigrate; panel-side
  metadata only, never reaches xray itself
- service/forwarding.go: ForwardingSpec (mode direct/forward; landing
  managed|manual), save-time validation (managed target must exist and
  be a SOCKS-family landing — the fork's SK5 inbound is 'mixed'; no
  self-forward; manual endpoint/protocol sanity), live resolution
  (managed: concrete listen IP wins, else hosting node's address;
  credentials from the target's first account), and the compiler that
  appends one socks/http outbound (tag fwd-<inboundId>) plus a prepended
  inboundTag rule per forwarding inbound
- buildXrayConfig agent path: compile runs after applyAgentEgress, so
  entries ride on the live template (or stored egress override) and
  template edits keep flowing; unresolvable entries skip with a warning
  instead of failing the whole config
- UpdateInbound's field-merge now carries Forwarding (its absence was
  caught by the smoke: adds worked, updates silently kept the old spec)
- GET /panel/api/inbounds/forwardTargets: managed-landing candidates
  (id/remark/tag/listen/port/node name) — credentials never exposed
- frontend: new Forwarding tab in the inbound form (mode, landing type,
  managed picker, manual endpoint with link import incl. base64 form),
  adapter round-trips the spec as a JSON string like settings; en/zh
  strings; generated API types refreshed

Acceptance: unit tests for parse/validate/resolve/compile incl. the
dynamic-recompile proof (template edit + spec edit both reflected), and
an end-to-end smoke (real binary, two agent nodes, all inbound writes
in browser form-urlencoded format): 22/22 PASS — see P9-REPORT.md.
2026-10-21 17:40:42 +00:00
Muse ceca96571d P9(5): restore upstream Telegram bot (settings, backup, notifications)
Per user decision 2026-10-21 (reverses the 2026-10-20 drop-Telegram
call): port the upstream 3x-ui v3.7.0-generation Telegram bot wholesale
instead of rewriting a minimal set, adapted to this fork.

- new internal/web/service/tgbot package (telego): bot lifecycle,
  commands, reports, callback router, send helpers; token validation via
  telego; custom API server URL passes the fork's SSRF sanitizer
  (loopback/private bases fall back to the default endpoint, logged)
- settings: full upstream key set (tgBotEnable/Token/Proxy/APIServer/
  ChatId, tgRunTime, tgBotBackup, tgCpu/tgMemory, tgLang,
  tgEnabledEvents) with getters/setters; settings page gains a Telegram
  tab + sidebar entry; token shown masked, clearTgBotToken honored
- wiring (web.go): stats-notify cron registered from tgRunTime when the
  bot is enabled; CheckHashStorage job; CPU/memory alarm jobs gated on
  tgEnabledEvents + thresholds; login notifications via event bus;
  POST /panel/api/setting/testTgBot and /panel/api/backuptotgbot
- lite adaptation: StatsNotifyJob no longer gates on a local Xray core
  (this panel never runs one) so the periodic report and the scheduled
  DB backup (sendDocument) can actually fire
- translations: upstream TG keys restored across all locales

End-to-end smoke (real binary + fake Telegram API over HTTPS at a
public-looking address, browser form wire format): bot starts on
settings change (getMe), testTgBot delivers, panel restart re-arms the
bot, tgRunTime='@every 1m' cron really fires sendDocument carrying the
panel SQLite DB (magic verified) plus the report sendMessage, login
notification delivered, dead-endpoint failure is logged not silent.
13/13 PASS — see P9-REPORT.md.
2026-10-21 17:16:32 +00:00
Muse 5031a70ccb P9(4): remove local-core live-test trio — outbound speed test + egress probe, restart/stop xray endpoints
Per user decision 2026-10-21 (reverses the earlier keep call in
PANEL-LITE-SPEC.md, now amended): the lite panel never runs a local
core, so measuring/probing from the panel host is meaningless.

Backend:
- delete service/outbound probe implementation (probe_http.go and its
  tests) and the TestOutbound/TestOutbounds batch prober; outbound
  traffic accounting (AddTraffic/GetOutboundsTraffic/Reset) unchanged
- drop POST /panel/api/xray/testOutbound + testOutbounds routes and the
  outboundTestUrl setting (get/update plumbing removed; stored rows in
  existing DBs are inert)
- drop POST /panel/api/server/restartXrayService + stopXrayService
  routes/handlers and the node-sync allowlist entry; the service-level
  Stop/RestartXrayService stay for internal import/restore flows
- node-sync allowlist contract test updated to the new inventory

Frontend:
- remove test buttons/mode switch/test-all from outbounds tab, card
  list and table columns; delete TestResultPopover and CountryPill;
  drop the egress/country/latency columns fed by probe results
- remove outboundTestUrl state and save plumbing from useXraySetting
  and the Basics tab field; drop OutboundTestResult schemas
- delete the 15 now-dead i18n keys from all 13 locales (incl. the
  restart/stop keys orphaned since P6/P7 — i18n-dead-keys test green)
- tests: drop the test-URL hook test, re-probe the loopback index
  mapping through Move-to-top, regenerate API types (gen)
2026-10-21 16:35:53 +00:00
Muse 6772c97f8e P8.1: fix browser inbound re-homing — nodeId probe must parse form-encoded bodies
updateInbound detected an explicit nodeId by probing the raw request
body as JSON only (requestHasJSONKey). The panel frontend, however,
posts application/x-www-form-urlencoded by default (http-init.ts
form-encodes every POST without a declared JSON Content-Type), so the
probe missed on every real browser save and UpdateInbound silently kept
the stored assignment — the edit dialog's 部署到 picker never moved
anything, while JSON API callers (and the P8 E2E, which posted JSON)
worked.

requestHasKey is now content-type aware: JSON bodies are probed as
before; urlencoded bodies are parsed with url.ParseQuery, where the
browser's null encoding (a bare "nodeId=") also counts as provided —
it binds as *int(0) and the existing 0->nil normalisation turns it
into the move-to-local target.

Regression tests post the exact browser wire format through the real
handler stack: form move between nodes, form post without nodeId
(assignment kept), form post with nodeId= (move to local, no binding
error), plus the JSON move at HTTP level. The two form-move tests
fail on the unfixed code and pass with the fix.
2026-10-21 15:37:45 +00:00
Muse 7284fa6cf0 P8(2): inbound node reassignment (move) with backend re-homing
An inbound's 部署到 node can now be changed after creation; the row,
clients, client_traffics and subscription output stay untouched (same
inbound id throughout).

Contract: the update payload's nodeId counts only when the key was
explicitly sent — the controller probes the raw JSON body and sets
Inbound.NodeIDProvided; the wire adapter now always emits nodeId (null =
local) so clearing the picker is an explicit move-to-local, while
callers that omit nodeId keep the stored assignment (legacy behaviour).

UpdateInbound resolves the target before the port-conflict check (so
conflicts are scoped to the target node), rejects moves to a missing
node, re-derives auto tags under the target node's prefix, validates
the tag against the target node's namespace, and marks BOTH nodes
dirty: the source node's next reconcile sweeps the old tag (no silent
ghost — the flag persists until a reconcile succeeds, so an offline
side just defers), the target's pushes the inbound. Agent-bound nodes
converge via GetXrayConfigForAgent on the agent's next config poll.
A local side has no reconcile loop, so it is applied directly
post-commit (Del/Add on the local runtime, restart fallback).

Frontend: the deploy-to Select is no longer disabled in edit mode
(protocol stays locked); candidate rules unchanged (online nodes only).

Tests: panel->panel move with reconcile sweep/push assertions,
auto-tag prefix regeneration, agent-config flip in both directions,
omitted-nodeId legacy contract, same-node no-op, missing-node
rejection, node->local move.
2026-10-21 13:49:57 +00:00
Muse 261a1f6787 P8(1): restore homepage overview curves on a memory-only sampling ring
Backend: new overviewMetrics store — a single-tier (2s x 360 = 12min)
in-memory ring fed by ServerService.RefreshStatus for exactly the eight
overview series (cpu/mem/swap/diskUsage/netUp/netDown/tcpCount/udpCount).
No persistence, no gob snapshot, no system-history page: the only surface
is GET /panel/api/server/history/:metric/:bucket restricted to those
keys, which seeds the frontend curves. ~46KB bounded by construction;
the ring starts empty on every panel start.

Frontend: restore useOverviewHistory + the VitalTile/ThroughputCard/
ConnectionsCard sparklines and the IndexPage wiring exactly as they
were before P7 removed them along with the history module.
2026-10-21 13:49:43 +00:00
Muse 60ce5eab98 docs(spec): record scope decision — keep protocol range, IP check/speedtest, ACME, multi-user 2026-10-21 21:09:29 +08:00
Muse f887f47bb9 P7: restore agent node type, agent heartbeat expiry, build-version injection, remove log/history/xray-metrics modules, optional sub port
- nodes: port node-type (traditional/agent) picker back from web-src into
  the embedded frontend; backend annotates NodeView.nodeType from
  agent_nodes bindings; fix agent-node update path (skip reachability
  probe and apiToken requirement for agent-managed nodes); restore i18n
  keys in all 13 locales; install command now uses the Gitea mirror
- jobs: expire agent-bound nodes whose agent_nodes.last_seen is older
  than max(3x heartbeat interval, 120s) = 180s; last_seen=0 (never
  registered) is left untouched; transitions reuse UpdateHeartbeat +
  node event broadcast
- config: GetBaseVersion honors ldflags-injected buildVersion; release
  builds inject 3.7.0-agent.1 so the panel no longer reports a phantom
  update against the Gitea release tag
- logger/server: remove the three log viewers (panel/xray/amneziawg),
  their API endpoints and the 10240-entry in-memory log buffer; file
  logging via lumberjack unchanged; tests assert via a new optional
  logger hook instead of the removed buffer
- server: remove system history and xray metrics modules end to end
  (endpoints, sampling appends in RefreshStatus, per-minute persist
  cron, gob snapshot/restore, XrayMetricsService); realtime status and
  per-node history are untouched
- settings: subPort may be 0/unset - subscription links are generated
  without a port while the subscription service keeps binding the
  default 2096; the settings form declares the cleared state via
  onNumber's new onClear option instead of snapping back to 1
2026-10-21 06:44:00 +00:00
Rogue 2c85f85549 精简版第六轮补裁(P6): 摘除分组/API文档死入口与首页Xray状态徽标及重启/停止按钮, 清理死i18n键 2026-10-20 19:25:29 +00:00
Muse 0f0335e943 修复二开 agent 流量增量丢失: 基线改上报确认后推进并落盘持久化
- xray 新增快照/确认接口 GetTrafficSnapshot/CommitTrafficSnapshot/
  RestoreTrafficBaseline, 与面板 GetTraffic 的 StatsLastValues 完全隔离
- agent 改走快照: 仅主控 HTTP 200 后才推进基线, 失败轮增量由累计
  计数做差在下轮自动补齐; 已确认基线原子落盘, agent 重启恢复续差
- 核心重启计数归零的从零规则保留; Onlines 仍每轮新鲜采集直报
- 新增 11 个测试覆盖断网恢复/连续失败补齐/重启恢复/计数归零/
  面板语义隔离, xray 与 agent 两包全绿
2026-10-20 15:58:38 +00:00
Muse 4828f946b1 精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

Xray更新后端(经调查确认仅服务本机bin/xray, 与agent下发/订阅无关):
- 删除 getXrayVersion/installXray/updateGeofile 路由与handler(访问返回404)
- 删除 ServerService 对应实现与校验和函数; 同步api-docs/monitor白名单/openapi

本机核心监管下线(设计: 主控永不运行本机xray, 入站全在agent):
- XrayService.RestartXray 降级为no-op(一次性Warning说明日志); StopXray无进程时幂等返回
- web.go 摘除启动拉起本机核心/CheckXrayRunning看门狗(1s)/ApplyPendingRestart cron(30s)/XrayTrafficJob/CheckClientIpJob 注册
- ApplyPendingRestart 降级为消费即清, 其测试更新为新语义
- XrayMetricsService.Sample 短路: 不再每2s探本机/debug/vars, 状态优雅置为不可用
- 配置生成(纯JSON)/agent链路/订阅/入站CRUD未动; 状态读数优雅归零(stop/Unknown)

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
2026-10-20 14:58:05 +00:00
Muse 945b3a56a2 链接迁移: 前端用户可见链接与 .github 元数据改指本仓 Gitea
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-latest) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-latest) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
- 前端: 侧边栏 REPO_URL 改指本仓 Gitea; 订阅设置帮助链接改指本仓
  Gitea 上的 docs/custom-subscription-templates.md (dist 产物未动, 下次构建自愈)
- FUNDING.yml: 删除上游作者的赞助句柄 (github/buy_me_a_coffee/custom 捐赠页),
  留注释说明; ISSUE_TEMPLATE: Wiki/搜索/最新版三个 contact_links 与
  question.yaml 的 Wiki 链接改指本仓 Gitea (仓库首页/Issues/Releases)
- .github/claude/repo-context.md: 安装命令一行改指本仓 install-master.sh
2026-10-20 12:41:56 +00:00
Muse b8e132fc0b 链接迁移: 6 个多语言 README 与 CONTRIBUTING 改指本仓 Gitea
- 快速开始主命令改指本仓 install-master.sh (Gitea raw); 无等价物的
  指定版本/dev 通道一键命令替换为指向主 README 的英文说明行 (小节结构保留)
- 删除 5 个绑定上游仓库的 shields/pkg.go.dev 徽章 (无 Gitea 等价物);
  Wiki 链接改指本仓首页; Docker 示例镜像由上游发布的 ghcr.io/mhsanaei/3x-ui
  改为本地构建的 3x-ui-agent:local (fork 不发布注册表镜像)
- 删除上游作者个人捐赠锚点 (buymeacoffee/nowpayments) 与 starchart 上游统计小节
- CONTRIBUTING: clone 地址与 Issues 链接改指本仓 Gitea
2026-10-20 12:40:52 +00:00
Muse c67f0cb0cf 链接迁移: fork 脚本复活指 Gitea、上游三件套中和、面板自更新链改指本仓
- install-master.sh/install-agent.sh: 二进制下载源由已封账号 aaaamoooo/3x-ui-diy
  改指本仓 Gitea Releases (tag 沿用 v3.7.0-agent.1); README 同步撤下'暂不可用'标注
- install.sh/update.sh: 未被 fork 部署链引用, 整文件替换为中英双语短桩 (exit 1),
  防旧习惯调用时静默装成/更新成上游原版
- x-ui.sh: 保留全部管理功能, 仅中和 install/update/update_dev/update_menu/
  legacy_version/update_shell 的上游下载分支与 uninstall 打印的上游命令,
  一律改为提示本仓 Gitea Releases 地址后退出该分支
- panel.go: 面板自更新运行时耦合处理 — panelUpdaterURL 与版本检查 API 由
  MHSanaei GitHub 改指本仓 Gitea (raw update.sh + /api/v1 releases, 响应字段
  tag_name/body/target_commitish 与原解析结构一致); 下载到的将是本仓 update.sh
  短桩, 面板更新会安全失败而非覆盖成原版
- deploy/cloud-init: curl 上游 install.sh 改为本仓 install-master.sh (Gitea raw);
  头部注明 XUI_* 非交互旋钮与 install-result.env 凭据回显在 fork 安装脚本下无等价物
2026-10-20 12:36:50 +00:00
Muse 8da3cde494 docs: README 改版为 fork 口径——删上游/已封 GitHub 的一键安装命令,改为 Gitea Releases 手动部署主控/agent 两模式,补卸载章节 2026-10-20 12:27:21 +00:00
910 changed files with 48893 additions and 78239 deletions
+5 -3
View File
@@ -1,6 +1,8 @@
# These are supported funding model platforms
# Fork note: the upstream author's funding handles (github / buy_me_a_coffee /
# custom donate page) were removed — they do not belong to this fork.
github: MHSanaei
github: # upstream author's handle removed in this fork
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
@@ -10,5 +12,5 @@ liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: mhsanaei
custom: https://donate.sanaei.dev/
buy_me_a_coffee: # upstream author's handle removed in this fork
custom: # upstream author's donation page removed in this fork
+4 -4
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📖 Project Wiki
url: https://github.com/MHSanaei/3x-ui/wiki
- name: 📖 Project home (Gitea)
url: https://git.881218.xyz/rogue/3x-ui-agent
about: Setup, install, configuration, and "how do I…" guides live here. Please check before opening a question.
- name: 🔍 Search existing issues
url: https://github.com/MHSanaei/3x-ui/issues?q=is%3Aissue
url: https://git.881218.xyz/rogue/3x-ui-agent/issues
about: Your bug, feature, or question may already be tracked. Comment on the existing one rather than opening a duplicate.
- name: 🚀 Latest release
url: https://github.com/MHSanaei/3x-ui/releases/latest
url: https://git.881218.xyz/rogue/3x-ui-agent/releases
about: Reproduce on the latest version before reporting — many bugs are already fixed in the most recent release.
+2 -2
View File
@@ -9,7 +9,7 @@ body:
value: |
Before opening a question, please:
- Read the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki) —
- Read the [project home](https://git.881218.xyz/rogue/3x-ui-agent) —
most setup, install, and "how do I …" answers live there.
- Search [existing issues](../issues?q=is%3Aissue) — the question
may already have been asked.
@@ -85,7 +85,7 @@ body:
attributes:
label: Before submitting
options:
- label: I read the [Wiki](https://github.com/MHSanaei/3x-ui/wiki) and searched [existing issues](../issues?q=is%3Aissue).
- label: I read the [project home](https://git.881218.xyz/rogue/3x-ui-agent) and searched [existing issues](../issues?q=is%3Aissue).
required: true
- label: This is a usage question, not a bug report.
required: true
+1 -1
View File
@@ -153,7 +153,7 @@ test that cannot fail is invisible to CI. `make verify` is the local gate.
## Support facts reporters get wrong
- Linux install: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
- Linux install: `bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)`
- Install generates a RANDOM username, password and web base path — never
admin/admin. The `x-ui` menu on the server shows or resets them.
- The installer service environment file is DISTRO-DEPENDENT:
+3
View File
@@ -19,6 +19,9 @@ backup/
bin/
x-ui/
dist/
# Exception: the vendored embedded fallback theme ships its dist/ as
# source (go:embed) — it is not a build output.
!/internal/web/service/probe_theme_default/dist/
release/
node_modules/
+3 -3
View File
@@ -50,8 +50,8 @@ Cross-building the Linux SQLite target from Windows (or vice versa) requires a s
## First-time setup
```bash
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
git clone https://git.881218.xyz/rogue/3x-ui-agent.git
cd 3x-ui-agent
cp .env.example .env
@@ -315,6 +315,6 @@ must match the override, for example `XUI_PORT: "8080"` with `ports: ["8080:8080
## Issues
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
- Bug reports and feature requests: [Issues](https://git.881218.xyz/rogue/3x-ui-agent/issues)
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
+8 -5
View File
@@ -24,13 +24,16 @@
- 安全设置:双重验证(2FA,用户不用)
- 邮件模块(SMTP 发送:到期/流量提醒等,用户不用)
- 分组功能(客户端归类标签 + 按组批量操作/聚合订阅;用户从未用过,裁功能不影响已建客户端与订阅)
- 本地核心实测三件套(P9 第 4 条,用户 2026-10-21 晚改口):出站测速(临时 xray 实例批量探测 `testOutbound(s)` 接口 + 前端测试按钮/模式切换/结果弹层)、出口探测(同一动作的 egress IP/国家展示)、重启/停止核心接口(`restartXrayService`/`stopXrayService`,与首页已摘按钮同族);出站与路由的模板编辑(增删改存)保留不动
- 本地核心整条路径(P9 第 4b 条):进程管理/状态机、buildXrayConfig 本地分支与 local-only 注入(panel/node egress 桥、订阅出站合并、mtproto/amneziawg 桥)、本地核心专属接口(balancer 状态/override、routeTest、getXrayResult)与前端 RouteTester、本地核心三个 job(流量采集/存活看门狗/客户端 IP 限制)已整条删除;配置构建只剩 agent 一条线(模板 + 节点入站 + egress + 转发编译)。MTProto/AmneziaWG 随之退役:新建/编辑直接拒绝,存量行保留在库中可删除、不再被服务。金标准验证:同一种子库删除前后三台 agent 的生成配置逐字一致
## 待用户确认
## 范围确认(2026-10-21 用户拍板:四项全部保留,不裁)
- 协议范围:是否只留 VLESS + SS2022,裁其余协议模板(VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等)——建议裁
- IP 检查 / 测速类后台任务——建议裁
- 面板内置 ACME 签证书(走 CF Tunnel,不直签)——建议裁
- 多用户功能(单管理员使用)——建议裁
- 协议范围:保留全部协议模板(VLESS、SS2022、VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等),不收窄
- IP 检查 / 测速类后台任务:保留 → **2026-10-21 晚用户改口推翻本项**:出站测速 + 出口探测(同一动作,测的是主控本地网络位置;精简主控不跑本机核心、无本地出站,测速结果无意义)连同重启/停止核心控件与接口,已于 P9 第 4 条摘除。其余三项(协议范围 / ACME / 多用户)保留不变,不要再翻出来问。
- 面板内置 ACME 签证书:保留
- 多用户功能:保留
- 用户理由:这四块以静态代码为主,裁掉省不了多少内存。此节关闭,后续轮次不要再翻出来问。
## 修复(非裁剪,已定)
+5 -22
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** هي لوحة تحكم ويب متقدمة ومفتوحة المصدر لإدارة خوادم [Xray-core](https://github.com/XTLS/Xray-core). توفّر واجهة نظيفة ومتعددة اللغات لنشر وتكوين ومراقبة مجموعة واسعة من بروتوكولات الوكيل وVPN — من خادم VPS واحد إلى عمليات النشر متعددة العقد.
@@ -69,24 +64,24 @@
## البدء السريع
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
لتثبيت إصدار محدد، أضِف وسمه (مثل `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
لتثبيت بنية **dev** المتجددة (أحدث إصدار أولي لكل التزام (commit) من `main`، وليس إصدارًا مستقرًا)، مرّر `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
أثناء التثبيت، يتم إنشاء اسم مستخدم وكلمة مرور ومسار وصول عشوائية. بعد التثبيت، شغّل `x-ui` لفتح قائمة الإدارة، حيث يمكنك بدء/إيقاف الخدمة، وعرض أو إعادة تعيين بيانات تسجيل الدخول، وإدارة شهادات SSL، والمزيد.
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://git.881218.xyz/rogue/3x-ui-agent).
### التثبيت غير التفاعلي
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
تتضمن الصورة Fail2ban (مُفعَّل افتراضيًا) لفرض **حدود IP** لكل عميل. يحظر Fail2ban المخالفين باستخدام `iptables`، الذي يتطلب صلاحية `NET_ADMIN`. يمنح `docker-compose.yml` هذه الصلاحية مسبقًا عبر `cap_add`؛ إذا شغّلت الحاوية باستخدام `docker run` بدلاً من ذلك، فأضِف الصلاحيات بنفسك، وإلا فسيتم تسجيل عمليات الحظر دون تطبيقها أبدًا:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## متغيرات البيئة
@@ -191,15 +186,3 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
## دعم المشروع
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## النجوم عبر الزمن
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+5 -23
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** es un panel de control web avanzado y de código abierto para gestionar servidores [Xray-core](https://github.com/XTLS/Xray-core). Ofrece una interfaz limpia y multilingüe para desplegar, configurar y monitorear una amplia gama de protocolos de proxy y VPN — desde un único VPS hasta despliegues multinodo.
@@ -69,24 +64,24 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
## Inicio Rápido
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
Para instalar una versión específica, añade su etiqueta (p. ej. `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
Para instalar la versión **dev** continua (la última prelanzamiento por commit desde `main`, no una versión estable), pasa `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
Durante la instalación se generan un nombre de usuario, una contraseña y una ruta de acceso aleatorios. Tras la instalación, ejecuta `x-ui` para abrir el menú de gestión, donde puedes iniciar/detener el servicio, ver o restablecer tus credenciales de acceso, gestionar certificados SSL y mucho más.
Para la documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
Para la documentación completa, visita la [Wiki del proyecto](https://git.881218.xyz/rogue/3x-ui-agent).
### Instalación desatendida
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
La imagen incluye Fail2ban (habilitado de forma predeterminada) para aplicar **límites de IP** por cliente. Fail2ban banea a los infractores con `iptables`, lo que requiere la capacidad `NET_ADMIN`. `docker-compose.yml` ya la concede mediante `cap_add`; si en su lugar inicias el contenedor con `docker run`, añade tú mismo las capacidades, de lo contrario los baneos se registran pero nunca se aplican:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## Variables de Entorno
@@ -191,16 +186,3 @@ Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
## Apoyar el Proyecto
**Si este proyecto te es útil, puedes darle una**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Estrellas a lo Largo del Tiempo
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+5 -23
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** یک پنل کنترل وب پیشرفته و متن‌باز برای مدیریت سرورهای [Xray-core](https://github.com/XTLS/Xray-core) است. این پنل یک رابط کاربری تمیز و چندزبانه برای استقرار، پیکربندی و نظارت بر طیف گسترده‌ای از پروتکل‌های پراکسی و VPN ارائه می‌دهد — از یک VPS تکی تا استقرارهای چندنودی.
@@ -69,24 +64,24 @@
## شروع سریع
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
برای نصب یک نسخه‌ی مشخص، تگ آن را در انتها اضافه کنید (مثلاً `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
برای نصب نسخه‌ی غلتانِ **dev** (آخرین پیش‌انتشار به‌ازای هر کامیت از شاخه‌ی `main`، نه یک انتشار پایدار)، مقدار `dev-latest` را پاس دهید:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
در حین نصب، یک نام کاربری، رمز عبور و مسیر دسترسی تصادفی تولید می‌شود. پس از نصب، دستور `x-ui` را اجرا کنید تا منوی مدیریت باز شود؛ در آنجا می‌توانید سرویس را شروع/متوقف کنید، اطلاعات ورود خود را ببینید یا بازنشانی کنید، گواهی‌های SSL را مدیریت کنید و کارهای دیگری انجام دهید.
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
برای مستندات کامل، لطفاً به [ویکی پروژه](https://git.881218.xyz/rogue/3x-ui-agent) مراجعه کنید.
### نصب بدون نظارت
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
این ایمیج، Fail2ban را (که به‌صورت پیش‌فرض فعال است) برای اعمال **محدودیت‌های IP** به‌ازای هر کلاینت همراه دارد. ‏Fail2ban متخلفان را با `iptables` مسدود می‌کند که به مجوز `NET_ADMIN` نیاز دارد. فایل `docker-compose.yml` این مجوز را از قبل از طریق `cap_add` می‌دهد؛ اگر به‌جای آن کانتینر را با `docker run` اجرا می‌کنید، خودتان مجوزها را اضافه کنید، در غیر این صورت مسدودسازی‌ها فقط ثبت می‌شوند اما هرگز اعمال نمی‌شوند:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## متغیرهای محیطی
@@ -191,16 +186,3 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
## پشتیبانی از پروژه
**اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## ستاره‌ها در طول زمان
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+74 -13
View File
@@ -1,39 +1,98 @@
# 3x-ui-agent
基于 3x-ui v3.7.0 的二次开发:节点 agent 化。节点鸡不再跑完整面板,只跑一个轻量 agent(约 60MB 内存),由主控统一管理、下发 xray 配置。
基于上游 3x-ui v3.7.0 的二次开发 fork:节点 agent 化。节点鸡不再跑完整面板,只跑一个轻量 agent(约 60MB 内存),由主控统一管理、下发 xray 配置。
本仓库托管在自建 Gitea:<https://git.881218.xyz/rogue/3x-ui-agent>,二进制从本仓库的 Releases 下载。**不要**使用上游的一键安装脚本/命令(`install.sh`、`update.sh` 等仍指向原版,会装成上游原版,与本 fork 不兼容)。
## 架构
- **主控**:完整 3x-ui 面板,唯一的配置可信源。管理节点、入站、客户端,下发完整 xray 配置(含出站、路由)到各 agent。
- **Agent**:跑在节点鸡上,通过 token 向主控注册。定时拉取主控下发的 xray 配置并应用,上报心跳和流量。只提供 VLESS / SS2022 入站。
- **Agent**:跑在节点鸡上,通过注册 token 向主控注册。定时拉取主控下发的 xray 配置并应用,上报心跳和流量。节点下发仅支持 VLESS / SS2022 两类入站(本 fork 的取向)。
同一个二进制、两种角色:不带参数运行是主控面板,`x-ui agent --config /etc/x-ui/agent.json` 运行是 agent。
## 安装
二进制下载:本仓库 [Releases](https://git.881218.xyz/rogue/3x-ui-agent/releases),按架构选 `x-ui-linux-amd64` 或 `x-ui-linux-arm64`。
> 仓库根目录的 `install-master.sh` / `install-agent.sh` 是一键脚本,下载源已迁至本仓 Gitea Releases(tag `v3.7.0-agent.1`),可直接使用;也可按下面步骤手动部署。
### 主控
```bash
bash <(curl -fL https://raw.githubusercontent.com/aaaamoooo/3x-ui-diy/main/install-master.sh)
mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放主控二进制(Releases 下载的 x-ui-linux-<架构>)
cp x-ui-linux-amd64 /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
# 2. 放 xray 二进制与 geo 文件到 bin/ 子目录
# 纯控制面部署(节点全走 agent)可禁用本机 xray:不放,或放了之后 chmod -x
cp xray-linux-amd64 /usr/local/x-ui/bin/
cp geoip.dat geosite.dat /usr/local/x-ui/bin/
```
装完面板在 `http://主控IP:2053`,默认账号 `admin` / `admin`。
启动(二选一):
- Debian/Ubuntu(systemd):把仓库的 `x-ui.service.debian` 复制为 `/etc/systemd/system/x-ui.service`,然后 `systemctl daemon-reload && systemctl enable --now x-ui`。
- OpenWrt(procd):用 init 脚本以 `/usr/local/x-ui/x-ui` 为命令启动,工作目录 `/usr/local/x-ui`。
数据库在 `/etc/x-ui/x-ui.db`。面板端口按设置,默认 2053,首次登录默认账号 `admin` / `admin`,请登录后立即修改。
### Agent(节点鸡)
先在主控面板「节点管理」→「添加节点」→ 类型选 **Agent**,保存后弹窗会显示 token,复制下来。
然后在节点鸡上:
先在主控侧为该节点生成注册 token(建节点 → 用面板 API Token 签发绑定该节点的 agent token,完整步骤见 [docs/AGENT.md](docs/AGENT.md)),然后在节点鸡上:
```bash
bash <(curl -fL https://raw.githubusercontent.com/aaaamoooo/3x-ui-diy/main/install-agent.sh) \
--master http://主控IP:2053 --token 你的TOKEN --name 节点名
mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放同一个二进制(与主控同版本),在节点上改名为 xui-agent(与主控进程区分、避免同机覆盖)
cp x-ui-linux-amd64 /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,需手动放置)
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
cp geoip.dat geosite.dat /usr/local/x-ui/bin/
# 3. 写 agent 配置
cat > /etc/x-ui/agent.json <<'EOF'
{
"master_url": "http://主控地址:2053",
"token": "<主控签发的注册 token>",
"node_name": "节点名"
}
EOF
chmod 600 /etc/x-ui/agent.json
```
agent 约 30 秒后拉到配置并启动 xray,节点在面板显示在线。
- `master_url` 走内网用 `http://`;走 `https://` 需可信证书(自签连不上)。
- agent 另有独立的运行时配置 `config-agent.json`(生成于 `bin/`);`agent.json` 可选 `api_port` 指定 xray stats API 端口(部署口径 62790,metrics 端口为其下一位 62791)。
- 启动:Debian 把仓库的 `systemd/x-ui-agent.service` 复制到 `/etc/systemd/system/` 后 `systemctl enable --now x-ui-agent`;OpenRC 用仓库的 `openrc/x-ui-agent` 放到 `/etc/init.d/x-ui-agent`。
agent 约 30 秒后拉到配置并启动 xray,节点在面板显示在线。更细的 agent 说明(出站/路由下发、验证、故障排查)见 [docs/AGENT.md](docs/AGENT.md)。
## 卸载
### 主控
```bash
# 先停服务:systemctl disable --now x-ui(或 /etc/init.d/x-ui stop)
rm -f /etc/systemd/system/x-ui.service # 或删掉对应的 procd init 脚本
rm -rf /usr/local/x-ui /etc/x-ui
```
### Agent
```bash
# 先停服务:systemctl disable --now x-ui-agent(或 rc-service x-ui-agent stop)
rm -f /etc/systemd/system/x-ui-agent.service # 或 /etc/init.d/x-ui-agent
rm -rf /usr/local/x-ui /etc/x-ui
```
(`/etc/x-ui` 里含数据库与 token 配置,确认不再需要再删。)
## 使用流程
1. 主控面板添加 Agent 类型节点,拿到 token
2. 节点鸡一键安装 agent,填入主控地址和 token
1. 主控侧建节点并签发 agent 注册 token(见 docs/AGENT.md)
2. 节点鸡按上面步骤部署 agent,填入主控地址和 token
3. 在「入站列表」创建入站(VLESS / SS2022),「部署到」选择在线的 agent 节点
4. 主控自动把完整 xray 配置下发到节点,客户端即可连接
@@ -44,12 +103,14 @@ agent 约 30 秒后拉到配置并启动 xray,节点在面板显示在线。
| agent + xray | 约 63MB |
| 完整 3x-ui 面板 | 130–160MB |
另有路由器主控精简版构建(剥离 xray-core 库,见仓库内 PANEL-LITE-SPEC.md),面向纯控制面部署,内存显著更低。
## 与原版的区别
- 节点不再需要安装完整面板
- 主控是唯一可信源,节点配置全部由主控下发
- 节点在线状态由 agent 心跳维持
- 添加节点支持 Agent 类型,自动生成注册 token
- 添加节点支持 Agent 类型,使用注册 token 接入
## License
+5 -23
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** — продвинутая веб-панель управления с открытым исходным кодом для управления серверами [Xray-core](https://github.com/XTLS/Xray-core). Она предоставляет аккуратный многоязычный интерфейс для развёртывания, настройки и мониторинга широкого спектра протоколов прокси и VPN — от одного VPS до развёртываний с несколькими узлами.
@@ -69,24 +64,24 @@
## Быстрый старт
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
Чтобы установить конкретную версию, добавьте её тег (например, `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
Чтобы установить скользящую **dev**-сборку (новейший предварительный релиз по каждому коммиту из ветки `main`, а не стабильный релиз), передайте `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
Во время установки генерируются случайные имя пользователя, пароль и путь доступа. После установки выполните `x-ui`, чтобы открыть меню управления, где можно запускать/останавливать сервис, просматривать или сбрасывать учётные данные для входа, управлять SSL-сертификатами и многое другое.
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
Полную документацию смотрите в [вики проекта](https://git.881218.xyz/rogue/3x-ui-agent).
### Автоматическая установка
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
Образ включает Fail2ban (включён по умолчанию) для применения **лимитов IP** по каждому клиенту. Fail2ban блокирует нарушителей с помощью `iptables`, что требует возможности `NET_ADMIN`. `docker-compose.yml` уже предоставляет её через `cap_add`; если вы вместо этого запускаете контейнер через `docker run`, добавьте возможности самостоятельно, иначе блокировки будут регистрироваться, но никогда не применяться:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## Переменные окружения
@@ -191,16 +186,3 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
## Поддержка проекта
**Если этот проект полезен для вас, вы можете поставить ему**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Звезды с течением времени
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+5 -23
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş profesyonel, açık kaynaklı bir web kontrol panelidir. Tek bir sanal sunucudan (VPS) çok düğümlü (multi-node) dağıtımlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
@@ -69,24 +64,24 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
## Hızlı Başlangıç
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
Belirli bir sürümü kurmak için, etiketini (ör. `v3.4.0`) ekleyin:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
Sürekli güncellenen **dev** sürümünü (kararlı bir sürüm değil; `main` dalından her commit'te oluşturulan en son ön sürüm) kurmak için `dev-latest` değerini geçirin:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu oluşturulur. Kurulumdan sonra, hizmeti başlatabileceğiniz/durdurabileceğiniz, giriş bilgilerinizi görüntüleyebileceğiniz veya sıfırlayabileceğiniz, SSL sertifikalarını yönetebileceğiniz ve çok daha fazlasını yapabileceğiniz yönetim menüsünü açmak için terminalde `x-ui` komutunu çalıştırın.
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://git.881218.xyz/rogue/3x-ui-agent) ziyaret edin.
### Etkileşimsiz kurulum
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
Docker imajı, kullanıcı başına **IP limitlerini** zorunlu kılmak için Fail2ban ile (varsayılan olarak etkindir) paketlenmiştir. Fail2ban, ihlalcileri `iptables` ile engeller ve bunun için `NET_ADMIN` yetkisine ihtiyaç duyar. `docker-compose.yml` bunu zaten `cap_add` üzerinden vermektedir; ancak konteyneri bunun yerine `docker run` ile başlatırsanız bu yetkileri kendiniz eklemelisiniz, aksi takdirde yasaklamalar günlüğe kaydedilir ancak uygulanmaz:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## Ortam Değişkenleri (Environment Variables)
@@ -191,16 +186,3 @@ Katkılarınızı her zaman bekliyoruz. Bir sorun (issue) açmadan veya pull req
## Projeyi Destekleyin
**Eğer bu proje size faydalı olduysa, bir yıldız verebilirsiniz**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Bana Bir Kahve Ismarla" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="NOWPayments üzerinden Kripto Bağış Butonu">
</a>
## Yıldız Tablosu
[![Zaman içerisindeki yıldız sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+6 -23
View File
@@ -8,12 +8,8 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** 是一个先进的开源 Web 控制面板,用于管理 [Xray-core](https://github.com/XTLS/Xray-core) 服务器。它提供简洁、多语言的界面,用于部署、配置和监控各种代理与 VPN 协议——从单台 VPS 到多节点部署。
@@ -69,24 +65,24 @@
## 快速开始
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
若要安装特定版本,请在命令后附加对应的标签(例如 `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
若要安装滚动更新的 **dev** 版本(来自 `main` 的最新逐次提交预发布版本,而非稳定版本),请传入 `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
安装过程中会生成随机的用户名、密码和访问路径。安装完成后,运行 `x-ui` 打开管理菜单,您可以在其中启动/停止服务、查看或重置登录凭据、管理 SSL 证书等。
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
完整文档请参阅 [项目Wiki](https://git.881218.xyz/rogue/3x-ui-agent)。
### 无人值守安装
@@ -139,7 +135,7 @@ docker compose --profile postgres up -d
该镜像捆绑了 Fail2ban(默认启用),用于强制执行按客户端的 **IP 限制**。Fail2ban 使用 `iptables` 封禁违规者,这需要 `NET_ADMIN` 权限。`docker-compose.yml` 已通过 `cap_add` 授予该权限;如果您改用 `docker run` 启动容器,请自行添加这些权限,否则封禁只会被记录而永远不会生效:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## 环境变量
@@ -191,16 +187,3 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
## 支持项目
**如果这个项目对您有帮助,您可以给它一个**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## 随时间变化的星标数
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+6 -3
View File
@@ -10,8 +10,11 @@
# serial console (owner-only); /etc/motd (world-readable) shows just the access
# URL + username. Nothing is baked in advance — every instance is unique.
#
# Requires the non-interactive install.sh (3x-ui with XUI_NONINTERACTIVE support).
# Edit the exported XUI_* knobs in /opt/xui-bootstrap.sh below to customise.
# Fork note (3x-ui-agent): the bootstrap below installs this fork's
# install-master.sh from its own Gitea repository. The fork installer does
# NOT consume the XUI_* non-interactive knobs and does NOT write
# /etc/x-ui/install-result.env, so the XUI_* exports and the credential-
# surfacing block below are inert leftovers from the upstream installer.
# ---------------------------------------------------------------------------
package_update: true
@@ -44,7 +47,7 @@ write_files:
# export XUI_DB_DSN="postgres://user:pass@host:5432/db?sslmode=disable"
# ------------------------------------------------------------------------
curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
curl -fsSL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh | bash
# Surface the generated credentials. Full creds (incl. password + API token)
# go ONLY to the serial console (/dev/console, owner-only). /etc/motd is
+2 -2
View File
@@ -37,8 +37,8 @@ curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
```bash
mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本)
cp x-ui /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本),在节点上改名为 xui-agent(与主控进程区分)
cp x-ui /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置)
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
@@ -54,7 +54,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
</>
);
}
@@ -69,7 +69,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
</>
);
}
@@ -69,7 +69,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
</>
);
}
@@ -66,7 +66,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
</>
);
}
+2 -3
View File
@@ -10,7 +10,7 @@
},
"scripts": {
"dev": "vite",
"build": "npm run gen:api && vite build",
"build": "vite build",
"preview": "vite preview",
"lint": "oxlint src tools",
"lint:fix": "oxlint --fix src tools",
@@ -22,8 +22,7 @@
"test:watch": "vitest",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node scripts/build-openapi.mjs",
"gen": "npm run gen:zod",
"gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true"
},
File diff suppressed because it is too large Load Diff
-246
View File
@@ -1,246 +0,0 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.ts';
import { EXAMPLES } from '../src/generated/examples.ts';
import { SCHEMAS } from '../src/generated/schemas.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json');
const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
const SECURITY_SCHEMES = {
bearerAuth: {
type: 'http',
scheme: 'bearer',
description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
},
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: '3x-ui',
description: 'Session cookie set by POST /login. Browser-only.',
},
};
function ginPathToOpenApi(path) {
return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
}
function extractPathParams(openApiPath) {
const params = [];
const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
let m;
while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
return params;
}
function mapType(t) {
const v = String(t || '').toLowerCase();
if (v.endsWith('[]')) return 'array';
if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
if (v === 'float' || v === 'double') return 'number';
if (v === 'boolean' || v === 'bool') return 'boolean';
if (v === 'array') return 'array';
if (v === 'object') return 'object';
return 'string';
}
function schemaFromType(t) {
const v = String(t || '').toLowerCase();
if (v.endsWith('[]')) {
const itemType = v.slice(0, -2);
return { type: 'array', items: { type: mapType(itemType) } };
}
return { type: mapType(v) };
}
function tryParseJson(raw) {
if (typeof raw !== 'string') return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function paramToOpenApi(p) {
const out = {
name: p.name,
in: p.in,
required: p.in === 'path' ? true : !p.optional,
description: p.desc || '',
schema: schemaFromType(p.type),
};
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
return out;
}
function buildOperation(ep, tag) {
const op = {
tags: [tag],
summary: ep.summary || '',
operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
};
if (ep.description) op.description = ep.description;
if (ep.deprecated) op.deprecated = true;
const params = [];
const bodyParams = [];
for (const p of ep.params || []) {
if (p.in === 'body') {
bodyParams.push(p);
} else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
params.push(paramToOpenApi(p));
}
}
const openApiPath = ginPathToOpenApi(ep.path);
const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
for (const name of extractPathParams(openApiPath)) {
if (declared.has(name)) continue;
params.push({
name,
in: 'path',
required: true,
description: '',
schema: { type: 'string' },
});
}
if (params.length > 0) op.parameters = params;
if (ep.body || bodyParams.length > 0) {
const example = tryParseJson(ep.body);
const properties = {};
const required = [];
for (const bp of bodyParams) {
properties[bp.name] = {
...schemaFromType(bp.type),
description: bp.desc || '',
};
if (!bp.optional) required.push(bp.name);
}
const schema = bodyParams.length > 0
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
: { type: 'object' };
op.requestBody = {
required: required.length > 0 || bodyParams.length === 0,
content: {
'application/json': {
schema,
...(example !== undefined ? { example } : {}),
},
},
};
}
const responses = {};
let successExample = tryParseJson(ep.response);
let objSchema = {};
if (ep.responseSchema) {
const obj = EXAMPLES[ep.responseSchema];
if (obj === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
}
if (SCHEMAS[ep.responseSchema] === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
}
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
if (successExample === undefined) {
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
}
}
responses['200'] = {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
obj: objSchema,
},
},
...(successExample !== undefined ? { example: successExample } : {}),
},
},
};
const errExample = tryParseJson(ep.errorResponse);
if (errExample !== undefined || ep.errorStatus) {
const code = String(ep.errorStatus || 400);
responses[code] = {
description: 'Error response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
},
},
...(errExample !== undefined ? { example: errExample } : {}),
},
},
};
}
op.responses = responses;
return op;
}
function buildSpec() {
const paths = {};
for (const section of sections) {
const tag = section.title;
for (const ep of section.endpoints) {
const openApiPath = ginPathToOpenApi(ep.path);
if (!paths[openApiPath]) paths[openApiPath] = {};
paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
}
}
const tags = sections.map((s) => ({
name: s.title,
description: s.description || '',
}));
return {
openapi: '3.0.3',
info: {
title: '3X-UI Panel API',
version: PANEL_VERSION,
description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
},
servers: [
{ url: '/', description: 'Current panel (basePath aware)' },
],
components: {
securitySchemes: SECURITY_SCHEMES,
schemas: SCHEMAS,
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
tags,
paths,
};
}
const spec = buildSpec();
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
const pathCount = Object.keys(spec.paths).length;
let opCount = 0;
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
console.log(`[openapi] wrote ${outPath}`);
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
void pathToFileURL;
+4
View File
@@ -127,6 +127,10 @@ async function performFetch(
if (data instanceof FormData) {
body = data;
headers.delete('Content-Type');
} else if (data instanceof Blob || data instanceof ArrayBuffer) {
// Raw payload (e.g. one chunk of a theme upload): sent as-is.
body = data as BodyInit;
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/octet-stream');
} else if (!SAFE_METHODS.has(upper)) {
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
if (declaredType.startsWith('application/json')) {
-111
View File
@@ -1,111 +0,0 @@
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
import { z } from 'zod';
import { keys } from '@/api/queryKeys';
import {
GeoCategoryPageSchema,
GeoEntryPageSchema,
GeoFileSchema,
GeodataTokenIssueSchema,
} from '@/generated/zod';
import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
const GeoFileListSchema = z.array(GeoFileSchema);
const GeodataTokenIssueListSchema = z.array(GeodataTokenIssueSchema);
const EMPTY_CATEGORY_PAGE: GeoCategoryPage = { total: 0, items: [] };
const EMPTY_ENTRY_PAGE: GeoEntryPage = { total: 0, items: [] };
export type GeoTokenKind = 'ip' | 'domain';
export interface ValidateGeoTokensInput {
tokens: string[];
kind: GeoTokenKind;
}
async function fetchGeodataFiles(): Promise<GeoFile[]> {
const msg = await HttpUtil.get('/panel/api/xray/geodata/files', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata files');
const validated = parseMsg(msg, GeoFileListSchema, 'xray/geodata/files');
return Array.isArray(validated.obj) ? validated.obj : [];
}
async function fetchGeodataCategories(file: string, query: string): Promise<GeoCategoryPage> {
const msg = await HttpUtil.get(
'/panel/api/xray/geodata/categories',
{ file, q: query },
{ silent: true },
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories');
return validated.obj ?? EMPTY_CATEGORY_PAGE;
}
async function fetchGeodataEntries(
file: string,
code: string,
query: string,
offset: number,
limit: number,
): Promise<GeoEntryPage> {
const msg = await HttpUtil.get(
'/panel/api/xray/geodata/entries',
{ file, code, q: query, offset, limit },
{ silent: true },
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata entries');
const validated = parseMsg(msg, GeoEntryPageSchema, 'xray/geodata/entries');
return validated.obj ?? EMPTY_ENTRY_PAGE;
}
export function useGeodataFiles(enabled: boolean) {
return useQuery({
queryKey: keys.xray.geodata.files(),
queryFn: fetchGeodataFiles,
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGeodataCategories(file: string | undefined, query: string, enabled: boolean) {
return useQuery({
queryKey: keys.xray.geodata.categories(file ?? '', query),
queryFn: () => fetchGeodataCategories(file ?? '', query),
enabled: enabled && !!file,
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
});
}
export function useGeodataEntries(
file: string | undefined,
code: string | undefined,
query: string,
offset: number,
limit: number,
enabled: boolean,
) {
return useQuery({
queryKey: keys.xray.geodata.entries(file ?? '', code ?? '', query, offset, limit),
queryFn: () => fetchGeodataEntries(file ?? '', code ?? '', query, offset, limit),
enabled: enabled && !!file && !!code,
placeholderData: keepPreviousData,
});
}
export function useValidateGeoTokens() {
return useMutation<GeodataTokenIssue[], Error, ValidateGeoTokensInput>({
mutationFn: async ({ tokens, kind }) => {
const msg = await HttpUtil.post(
'/panel/api/xray/geodata/validate',
{ tokens: tokens.join(','), kind },
{ silent: true },
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to validate geodata tokens');
const validated = parseMsg(msg, GeodataTokenIssueListSchema, 'xray/geodata/validate');
return Array.isArray(validated.obj) ? validated.obj : [];
},
});
}
+41 -23
View File
@@ -1,36 +1,58 @@
import { useQuery } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
import { fetchXrayConfig } from '@/hooks/useXraySetting';
// The outbound tag directory: the factory template's outbounds and
// routing balancers plus the tags contributed by outbound
// subscriptions. It used to be derived from the xray settings page's
// config payload; since the template became code-fixed (ITEM6 B4)
// the panel serves the same directory from a dedicated read endpoint.
export interface OutboundDirectory {
outbounds: { tag: string; protocol: string }[];
balancers: string[];
subscriptionOutboundTags: string[];
}
async function fetchOutboundDirectory(): Promise<OutboundDirectory> {
const msg = await HttpUtil.get('/panel/api/server/outboundDirectory', undefined, {
silent: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbound directory');
const obj = (msg.obj ?? {}) as Partial<OutboundDirectory>;
return {
outbounds: Array.isArray(obj.outbounds) ? obj.outbounds : [],
balancers: Array.isArray(obj.balancers) ? obj.balancers : [],
subscriptionOutboundTags: Array.isArray(obj.subscriptionOutboundTags)
? obj.subscriptionOutboundTags
: [],
};
}
// Available outbound (and balancer-eligible) tags the user can route an mtproto
// inbound's Telegram traffic to. Shares the cached xray config query so opening
// the inbound form costs no extra request when the Xray page was already
// inbound's Telegram traffic to. Shares one cached directory query so opening
// the inbound form costs no extra request when another picker already
// visited; `select` derives just the tag list without disturbing other readers.
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
queryKey: keys.server.outboundDirectory(),
queryFn: fetchOutboundDirectory,
staleTime: Infinity,
select: (data): string[] => {
const tags = new Set<string>();
for (const o of data?.xraySetting?.outbounds ?? []) {
const ob = o as { tag?: string; protocol?: string } | null;
for (const ob of data.outbounds) {
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
tags.add(ob.tag);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
for (const t of data.subscriptionOutboundTags) {
if (t) tags.add(t);
}
// Balancers are valid routing targets too — injectMtprotoEgress emits a
// balancerTag rule when the chosen tag names a balancer.
const balancers = (
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
)?.balancers;
for (const b of balancers ?? []) {
if (b?.tag) tags.add(b.tag);
for (const tag of data.balancers) {
if (tag) tags.add(tag);
}
return [...tags];
},
@@ -48,26 +70,22 @@ export interface OutboundTagGroups {
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
queryKey: keys.server.outboundDirectory(),
queryFn: fetchOutboundDirectory,
staleTime: Infinity,
select: (data): OutboundTagGroups => {
const outbounds = new Set<string>();
for (const o of data?.xraySetting?.outbounds ?? []) {
const ob = o as { tag?: string; protocol?: string } | null;
for (const ob of data.outbounds) {
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
outbounds.add(ob.tag);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
for (const t of data.subscriptionOutboundTags) {
if (t) outbounds.add(t);
}
const balancers: string[] = [];
const bal = (
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
)?.balancers;
for (const b of bal ?? []) {
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
for (const tag of data.balancers) {
if (tag && !outbounds.has(tag)) balancers.push(tag);
}
return { outbounds: [...outbounds], balancers };
},
@@ -1,41 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
import type { SubBalancerFormValues } from '@/schemas/subBalancer';
// Deliberately urlencoded (no JSON headers): the Go side binds inboundIds from
// repeated form keys, which is exactly how HttpUtil encodes arrays.
export function useSubBalancerMutations() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.subBalancers.root() });
const createMut = useMutation({
mutationFn: (payload: SubBalancerFormValues) =>
HttpUtil.post('/panel/api/sub-balancers', payload),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: SubBalancerFormValues }) =>
HttpUtil.post(`/panel/api/sub-balancers/${id}`, payload),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const removeMut = useMutation({
mutationFn: (id: number) => HttpUtil.post(`/panel/api/sub-balancers/${id}/del`),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
return {
create: (payload: SubBalancerFormValues) => createMut.mutateAsync(payload),
update: (id: number, payload: SubBalancerFormValues) => updateMut.mutateAsync({ id, payload }),
remove: (id: number) => removeMut.mutateAsync(id),
};
}
@@ -1,31 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import { SubBalancerListSchema, type SubBalancer } from '@/schemas/subBalancer';
async function fetchSubBalancers(): Promise<SubBalancer[]> {
const msg = await HttpUtil.get('/panel/api/sub-balancers', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch subscription balancers');
const validated = parseMsg(msg, SubBalancerListSchema, 'sub-balancers');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useSubBalancersQuery() {
const query = useQuery({
queryKey: keys.subBalancers.list(),
queryFn: fetchSubBalancers,
});
const balancers = useMemo(() => query.data ?? [], [query.data]);
return {
balancers,
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refetch: query.refetch,
};
}
+1 -18
View File
@@ -2,6 +2,7 @@ export const keys = {
server: {
status: () => ['server', 'status'] as const,
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
outboundDirectory: () => ['server', 'outboundDirectory'] as const,
},
nodes: {
root: () => ['nodes'] as const,
@@ -13,10 +14,6 @@ export const keys = {
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
tags: () => ['hosts', 'tags'] as const,
},
subBalancers: {
root: () => ['sub-balancers'] as const,
list: () => ['sub-balancers', 'list'] as const,
},
settings: {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
@@ -36,19 +33,5 @@ export const keys = {
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
activeInbounds: () => ['clients', 'activeInbounds'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const,
groups: () => ['clients', 'groups'] as const,
},
xray: {
root: () => ['xray'] as const,
config: () => ['xray', 'config'] as const,
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
geodata: {
root: () => ['xray', 'geodata'] as const,
files: () => ['xray', 'geodata', 'files'] as const,
categories: (file: string, query: string) =>
['xray', 'geodata', 'categories', file, query] as const,
entries: (file: string, code: string, query: string, offset: number, limit: number) =>
['xray', 'geodata', 'entries', file, code, query, offset, limit] as const,
},
},
} as const;
-7
View File
@@ -30,11 +30,6 @@ export function useWebSocketBridge() {
}, 200);
};
const onOutbounds: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
};
const onNodes: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.nodes.list(), payload);
@@ -46,14 +41,12 @@ export function useWebSocketBridge() {
};
client.on('invalidate', onInvalidate);
client.on('outbounds', onOutbounds);
client.on('nodes', onNodes);
client.on('inbounds', onInbounds);
client.connect();
return () => {
client.off('invalidate', onInvalidate);
client.off('outbounds', onOutbounds);
client.off('nodes', onNodes);
client.off('inbounds', onInbounds);
if (invalidateTimer != null) {
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Typography } from 'antd';
import dayjs from 'dayjs';
@@ -25,15 +25,6 @@ function ClientExpiryDemo() {
);
}
function JalaliDemo() {
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
useEffect(() => {
setDatepicker('jalalian');
return () => setDatepicker('gregorian');
}, []);
return <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />;
}
const meta = {
title: 'Form/DateTimePicker',
component: DateTimePicker,
@@ -43,7 +34,7 @@ const meta = {
docs: {
description: {
component:
'Calendar-aware date/time picker used for client and inbound expiry dates. Renders an AntD DatePicker by default and switches to a Persian (Jalali) calendar — with theme-matched colors and an overlaid clear button — when the panel datepicker setting is jalalian.',
'Date/time picker used for client and inbound expiry dates, rendered with the AntD DatePicker.',
},
},
},
@@ -90,9 +81,3 @@ export const DateOnly: Story = {
placeholder: 'Start date',
},
};
export const JalaliCalendar: Story = {
args: { value: null, onChange: () => undefined },
parameters: { docs: { disable: true } },
render: () => <JalaliDemo />,
};
@@ -1,14 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseCircleFilled } from '@ant-design/icons';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { PersianDateTimePicker } from 'persian-calendar-suite';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useTheme } from '@/hooks/useTheme';
import './DateTimePicker.css';
interface DateTimePickerProps {
value: Dayjs | null;
@@ -19,33 +10,6 @@ interface DateTimePickerProps {
disabled?: boolean;
}
const LIGHT_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#ffffff',
borderColor: '#d9d9d9',
hoverColor: 'rgba(22, 119, 255, 0.10)',
selectedTextColor: '#ffffff',
textColor: 'rgba(0, 0, 0, 0.88)',
};
const DARK_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#23252b',
borderColor: 'rgba(255, 255, 255, 0.12)',
hoverColor: 'rgba(22, 119, 255, 0.18)',
selectedTextColor: '#ffffff',
textColor: 'rgba(255, 255, 255, 0.88)',
};
const ULTRA_DARK_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#101013',
borderColor: 'rgba(255, 255, 255, 0.08)',
hoverColor: 'rgba(22, 119, 255, 0.16)',
selectedTextColor: '#ffffff',
textColor: 'rgba(255, 255, 255, 0.88)',
};
export default function DateTimePicker({
value,
onChange,
@@ -54,83 +18,6 @@ export default function DateTimePicker({
placeholder = '',
disabled = false,
}: DateTimePickerProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme();
const jalaliRef = useRef<HTMLDivElement>(null);
// Bumped on clear: persian-calendar-suite reads `value` only on mount, so
// remounting via key is the only way to reflect an externally cleared value.
const [clearNonce, setClearNonce] = useState(0);
// Mounted without a value, persian-calendar-suite seeds today and emits it —
// which would instantly undo a clear. Armed across every (re)mount.
const suppressMountEmit = useRef(true);
useEffect(() => {
suppressMountEmit.current = false;
return () => {
suppressMountEmit.current = true;
};
}, [clearNonce]);
const persianTheme = useMemo(() => {
if (isUltra) return ULTRA_DARK_THEME;
if (isDark) return DARK_THEME;
return LIGHT_THEME;
}, [isDark, isUltra]);
// The library hardcodes a Persian placeholder and exposes no working prop to
// override it, so clear it (or apply the caller's) on the input directly so
// the empty field shows no leftover Persian text. No dep array: re-apply
// after every render (incl. clear-remounts).
useEffect(() => {
if (datepicker !== 'jalalian') return;
const input = jalaliRef.current?.querySelector('input');
if (input) input.placeholder = placeholder;
});
if (datepicker === 'jalalian') {
return (
<div
ref={jalaliRef}
className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}${value ? '' : ' jdp-empty'}`}
>
<PersianDateTimePicker
key={clearNonce}
value={value ? value.valueOf() : null}
onChange={(next: number | string | null) => {
if (suppressMountEmit.current) return;
if (next == null || next === '') {
onChange(null);
return;
}
const ms = typeof next === 'number' ? next : Number(next);
if (Number.isFinite(ms)) onChange(dayjs(ms));
}}
showTime={showTime}
outputFormat="timestamp"
persianNumbers
rtlCalendar
theme={persianTheme}
/>
{value && !disabled && (
<button
type="button"
className="jdp-clear"
aria-label={t('clear')}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onChange(null);
setClearNonce((n) => n + 1);
}}
>
<CloseCircleFilled />
</button>
)}
</div>
);
}
return (
<DatePicker
value={value}
@@ -1,221 +0,0 @@
.geo-browser-modal .geo-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.geo-browser-modal .geo-toolbar .ant-input-search {
flex: 1;
min-width: 180px;
}
.geo-browser-modal .geo-meta {
margin-inline-start: auto;
font-size: 12px;
color: var(--ant-color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.geo-browser-modal .geo-columns {
display: grid;
grid-template-columns: minmax(240px, 340px) minmax(0, 1fr);
gap: 12px;
height: 440px;
}
/* Both panes are the same fixed height, and the pager sits on the pane's floor
rather than under the last row, so neither the dialog nor its controls move
as the user steps between categories with wildly different rule counts. */
.geo-browser-modal .geo-panel {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
}
.geo-browser-modal .geo-panel .ant-table-wrapper,
.geo-browser-modal .geo-panel .ant-spin-nested-loading,
.geo-browser-modal .geo-panel .ant-spin-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
}
.geo-browser-modal .geo-panel .ant-table {
flex: 1;
min-height: 0;
}
/* The rules table fills whatever is left between the header and the pager
instead of carrying a hardcoded scroll height, so there is no dead strip
above the pager and short categories do not scroll needlessly. */
.geo-browser-modal .geo-preview-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.geo-browser-modal .geo-pager {
margin-top: auto;
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
padding: 6px 12px;
border-top: 1px solid var(--ant-color-border-secondary);
font-variant-numeric: tabular-nums;
}
.geo-browser-modal .geo-pager .ant-pagination-total-text {
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.geo-browser-modal .geo-categories .ant-table-row {
cursor: pointer;
}
.geo-browser-modal .geo-row-active > td {
background: var(--ant-color-primary-bg);
}
.geo-browser-modal .geo-category {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.geo-browser-modal .geo-code,
.geo-browser-modal .geo-entry-value,
.geo-browser-modal .geo-preview-title {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.geo-browser-modal .geo-attrs .ant-tag {
font-size: 10px;
line-height: 16px;
margin-inline-end: 4px;
padding-inline: 4px;
}
.geo-browser-modal .geo-count {
font-variant-numeric: tabular-nums;
color: var(--ant-color-text-tertiary);
font-size: 12px;
}
.geo-browser-modal .geo-preview-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
/* The title is the part that gives way: without min-width it refuses to
shrink, and the filter is pushed onto a second line instead of the long
category name being clipped. */
.geo-browser-modal .geo-preview-title {
flex: 0 1 auto;
min-width: 0;
}
.geo-browser-modal .geo-preview-head .ant-typography {
flex: none;
white-space: nowrap;
}
.geo-browser-modal .geo-entry-filter {
flex: none;
width: 200px;
margin-inline-start: auto;
}
@media (max-width: 520px) {
.geo-browser-modal .geo-preview-head {
flex-wrap: wrap;
}
.geo-browser-modal .geo-entry-filter {
width: 100%;
}
}
.geo-browser-modal .geo-kind {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.geo-browser-modal .geo-kind-full {
color: var(--ant-color-success);
}
.geo-browser-modal .geo-kind-keyword {
color: var(--ant-color-warning);
}
.geo-browser-modal .geo-kind-regexp {
color: var(--ant-color-primary);
}
.geo-browser-modal .geo-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.geo-browser-modal .geo-footer {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--ant-color-border-secondary);
}
.geo-browser-modal .geo-chips {
flex: 1;
max-height: 76px;
overflow-y: auto;
}
.geo-browser-modal .geo-selected-count {
font-size: 12px;
color: var(--ant-color-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@media (max-width: 720px) {
.geo-browser-modal .geo-columns {
grid-template-columns: minmax(0, 1fr);
height: auto;
}
.geo-browser-modal .geo-panel {
height: 320px;
}
}
.geo-unknown-hint {
display: block;
margin-top: 4px;
font-size: 12px;
}
@@ -1,864 +0,0 @@
import { useEffect, useState, type ReactNode } from 'react';
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { expect, within } from 'storybook/test';
import { Button, Space, Typography } from 'antd';
import type { GeoCategory, GeoEntry, GeoFile } from '@/generated/types';
import GeoBrowserModal, { type GeoBrowserModalProps } from './GeoBrowserModal';
type GeoResponder = (query: URLSearchParams) => unknown;
type GeoRoutes = Record<string, GeoResponder>;
const realFetch = window.fetch.bind(window);
let activeRoutes: GeoRoutes = {};
function requestUrl(input: RequestInfo | URL): URL {
if (typeof input === 'string') return new URL(input, window.location.origin);
if (input instanceof URL) return input;
return new URL(input.url, window.location.origin);
}
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = requestUrl(input);
const responder = activeRoutes[url.pathname];
if (!responder) return realFetch(input, init);
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams) });
return Promise.resolve(
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
);
}
function activate(routes: GeoRoutes): void {
activeRoutes = routes;
window.fetch = geoFetch;
}
function deactivate(routes: GeoRoutes): void {
if (activeRoutes === routes) activeRoutes = {};
}
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
const [client] = useState(() => {
activate(routes);
return new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
});
useEffect(() => {
activate(routes);
return () => deactivate(routes);
}, [routes]);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
const full = (value: string): GeoEntry => ({ kind: 'full', value });
const keyword = (value: string): GeoEntry => ({ kind: 'keyword', value });
const regexp = (value: string): GeoEntry => ({ kind: 'regexp', value });
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
const cross = (names: string[], suffixes: string[]): GeoEntry[] =>
names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`)));
const CC_TLDS = [
'ae',
'al',
'am',
'at',
'az',
'ba',
'be',
'bg',
'bi',
'bj',
'ca',
'cat',
'cd',
'cf',
'cg',
'ch',
'ci',
'cl',
'cm',
'co.id',
'co.il',
'co.in',
'co.jp',
'co.ke',
'co.kr',
'co.ma',
'co.nz',
'co.th',
'co.uk',
'co.uz',
'co.ve',
'co.za',
'com.ar',
'com.au',
'com.bd',
'com.br',
'com.co',
'com.cu',
'com.eg',
'com.gt',
'com.hk',
'com.mx',
'com.my',
'com.ng',
'com.pe',
'com.ph',
'com.pk',
'com.sa',
'com.sg',
'com.tr',
'com.tw',
'com.ua',
'com.uy',
'com.vn',
'cz',
'de',
'dj',
'dk',
'dz',
'ee',
'es',
'fi',
'fr',
'ga',
'ge',
'gl',
'gm',
'gr',
'hn',
'hr',
'ht',
'hu',
'ie',
'iq',
'is',
'it',
'je',
'jo',
'kg',
'kz',
'la',
'li',
'lk',
'lt',
'lu',
'lv',
'ly',
'md',
'me',
'mg',
'mk',
'ml',
'mn',
'mu',
'mv',
'mw',
'ne',
'nl',
'no',
'nu',
'pl',
'pt',
'ro',
'rs',
'ru',
'rw',
'se',
'sh',
'si',
'sk',
'sm',
'sn',
'so',
'sr',
'st',
'td',
'tg',
'tk',
'tl',
'tm',
'tn',
'to',
'tt',
'vg',
'vu',
'ws',
];
const AD_HOSTS = [
'adform',
'adnxs',
'adroll',
'adsrvr',
'amplitude',
'appsflyer',
'bluekai',
'branch',
'casalemedia',
'criteo',
'flurry',
'moatads',
'mopub',
'openx',
'outbrain',
'pubmatic',
'quantserve',
'rubiconproject',
'scorecardresearch',
'sharethrough',
'smartadserver',
'taboola',
'teads',
'yieldmo',
'zemanta',
];
const CN_BRANDS = [
'58',
'alibaba',
'alipay',
'aliyun',
'baidu',
'bilibili',
'cnblogs',
'csdn',
'ctrip',
'douban',
'gitee',
'huawei',
'iqiyi',
'jd',
'kuaishou',
'meituan',
'netease',
'pinduoduo',
'qq',
'sina',
'sohu',
'taobao',
'tencent',
'tmall',
'toutiao',
'weibo',
'xiaomi',
'youku',
'zhihu',
];
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
amazon: [
domain('amazon.com'),
domain('amazonaws.com'),
domain('media-amazon.com'),
domain('ssl-images-amazon.com'),
domain('primevideo.com'),
domain('awsstatic.com'),
domain('cloudfront.net'),
full('www.amazon.co.jp'),
],
apple: [
domain('apple.com'),
domain('icloud.com'),
domain('cdn-apple.com'),
domain('mzstatic.com'),
domain('apple-cloudkit.com'),
domain('itunes.com'),
domain('me.com'),
domain('appstore.com'),
],
'category-ads': [
domain('adcolony.com'),
domain('applovin.com'),
domain('chartboost.com'),
domain('inmobi.com'),
domain('unityads.unity3d.com'),
keyword('banner-ad'),
],
'category-ads-all': [
domain('doubleclick.net'),
domain('googleadservices.com'),
domain('googlesyndication.com'),
domain('adservice.google.com'),
full('ads.yahoo.com'),
keyword('adservice'),
keyword('advertising'),
regexp('^ad[0-9]{1,3}\\.'),
...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
],
cloudflare: [
domain('cloudflare.com'),
domain('cloudflare-dns.com'),
domain('cloudflareinsights.com'),
domain('workers.dev'),
domain('pages.dev'),
domain('cf-ipfs.com'),
],
cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])],
discord: [
domain('discord.com'),
domain('discord.gg'),
domain('discordapp.com'),
domain('discordapp.net'),
domain('discord.media'),
],
facebook: [
domain('facebook.com'),
domain('fbcdn.net'),
domain('fb.com'),
domain('messenger.com'),
domain('fbsbx.com'),
domain('facebook.net'),
full('m.facebook.com'),
],
'geolocation-!cn': [
keyword('proxy'),
regexp('.*\\.onion$'),
domain('wikipedia.org'),
domain('bbc.com'),
domain('nytimes.com'),
domain('reuters.com'),
domain('medium.com'),
domain('reddit.com'),
],
'geolocation-cn': [
domain('gov.cn'),
domain('edu.cn'),
domain('org.cn'),
domain('net.cn'),
...cross(CN_BRANDS.slice(0, 18), ['cn']),
],
github: [
domain('github.com'),
domain('githubusercontent.com'),
domain('githubassets.com'),
domain('github.io'),
domain('ghcr.io'),
domain('git.io'),
],
google: [
domain('google.com'),
domain('googleapis.com'),
domain('gstatic.com'),
domain('googleusercontent.com'),
domain('google-analytics.com'),
domain('googletagmanager.com'),
domain('ggpht.com'),
domain('withgoogle.com'),
domain('android.com'),
domain('chromium.org'),
domain('abc.xyz'),
full('dl.google.com'),
...CC_TLDS.map((tld) => domain(`google.${tld}`)),
],
instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')],
microsoft: [
domain('microsoft.com'),
domain('live.com'),
domain('office.com'),
domain('office365.com'),
domain('windows.net'),
domain('windowsupdate.com'),
domain('msn.com'),
domain('azure.com'),
domain('sharepoint.com'),
domain('skype.com'),
domain('bing.com'),
],
netflix: [
domain('netflix.com'),
domain('netflix.net'),
domain('nflximg.com'),
domain('nflximg.net'),
domain('nflxvideo.net'),
domain('nflxso.net'),
domain('nflxext.com'),
full('fast.com'),
],
openai: [
domain('openai.com'),
domain('chatgpt.com'),
domain('oaistatic.com'),
domain('oaiusercontent.com'),
domain('sora.com'),
],
spotify: [
domain('spotify.com'),
domain('scdn.co'),
domain('spotifycdn.com'),
domain('spoti.fi'),
domain('spotifycdn.net'),
],
steam: [
domain('steampowered.com'),
domain('steamcommunity.com'),
domain('steamstatic.com'),
domain('steamcontent.com'),
domain('valvesoftware.com'),
],
telegram: [
domain('telegram.org'),
domain('telegram.me'),
domain('t.me'),
domain('telesco.pe'),
domain('tdesktop.com'),
domain('telegra.ph'),
domain('cdn-telegram.org'),
full('comments.app'),
keyword('telegram'),
],
tiktok: [
domain('tiktok.com'),
domain('tiktokcdn.com'),
domain('tiktokv.com'),
domain('byteoversea.com'),
domain('ibytedtos.com'),
domain('musical.ly'),
],
twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')],
twitter: [
domain('twitter.com'),
domain('x.com'),
domain('t.co'),
domain('twimg.com'),
domain('periscope.tv'),
],
whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')],
youtube: [
domain('youtube.com'),
domain('youtu.be'),
domain('ytimg.com'),
domain('googlevideo.com'),
domain('youtube-nocookie.com'),
domain('yt.be'),
],
};
const SITE_ATTRIBUTES: Record<string, string[]> = {
amazon: ['ads'],
apple: ['cn'],
facebook: ['ads'],
google: ['ads', 'cn'],
instagram: ['ads'],
microsoft: ['cn'],
tiktok: ['ads', 'cn'],
twitter: ['ads'],
youtube: ['ads'],
};
const CN_BLOCKS = [
'1.0.1.0/24',
'1.0.2.0/23',
'1.0.8.0/21',
'14.0.12.0/22',
'27.0.128.0/21',
'36.0.0.0/22',
'39.0.0.0/24',
'42.0.0.0/22',
'58.14.0.0/15',
'59.32.0.0/11',
'61.128.0.0/10',
'101.16.0.0/12',
'103.1.8.0/22',
'106.0.0.0/10',
'110.6.0.0/15',
'111.0.0.0/10',
'112.0.0.0/10',
'113.0.0.0/9',
'114.28.0.0/16',
'116.0.0.0/9',
'117.8.0.0/13',
'118.24.0.0/15',
'119.0.0.0/9',
'120.0.0.0/10',
'121.0.0.0/8',
'124.0.0.0/8',
'125.32.0.0/11',
'139.196.0.0/14',
'140.75.0.0/16',
'175.0.0.0/12',
'180.76.0.0/16',
'182.16.0.0/12',
'183.0.0.0/10',
'202.0.0.0/12',
'203.0.0.0/12',
'210.0.0.0/12',
'211.64.0.0/11',
'218.0.0.0/9',
'219.72.0.0/14',
'220.112.0.0/12',
'221.0.0.0/9',
'222.16.0.0/12',
'2001:250::/35',
'2400:3200::/32',
'2408:8000::/20',
];
const CN_EXTRA_BLOCKS = Array.from(
{ length: 96 },
(_, index) => `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
);
const IP_ENTRIES: Record<string, GeoEntry[]> = {
cloudflare: [
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'104.16.0.0/13',
'104.24.0.0/14',
'108.162.192.0/18',
'131.0.72.0/22',
'141.101.64.0/18',
'162.158.0.0/15',
'172.64.0.0/13',
'173.245.48.0/20',
'188.114.96.0/20',
'190.93.240.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'2400:cb00::/32',
'2606:4700::/32',
].map(cidr),
cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr),
facebook: [
'31.13.24.0/21',
'31.13.64.0/18',
'66.220.144.0/20',
'69.63.176.0/20',
'69.171.224.0/19',
'157.240.0.0/16',
'179.60.192.0/22',
'185.60.216.0/22',
'2a03:2880::/32',
].map(cidr),
google: [
'8.8.4.0/24',
'8.8.8.0/24',
'34.64.0.0/10',
'35.184.0.0/13',
'64.233.160.0/19',
'66.102.0.0/20',
'72.14.192.0/18',
'74.125.0.0/16',
'108.177.8.0/21',
'142.250.0.0/15',
'172.217.0.0/16',
'216.58.192.0/19',
'2404:6800::/32',
'2607:f8b0::/32',
].map(cidr),
ir: [
'2.144.0.0/14',
'5.22.0.0/17',
'31.2.128.0/17',
'37.32.0.0/19',
'46.32.0.0/19',
'78.38.0.0/15',
'80.191.0.0/16',
'85.15.0.0/18',
'91.98.0.0/15',
'178.22.72.0/21',
'185.8.172.0/22',
'188.34.0.0/17',
'217.218.0.0/15',
].map(cidr),
netflix: [
'23.246.0.0/18',
'37.77.184.0/21',
'45.57.0.0/17',
'64.120.128.0/17',
'66.197.128.0/17',
'108.175.32.0/20',
'185.2.220.0/22',
'192.173.64.0/18',
'198.38.96.0/19',
'198.45.48.0/20',
].map(cidr),
private: [
'0.0.0.0/8',
'10.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.0.0.0/24',
'192.0.2.0/24',
'192.168.0.0/16',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
'255.255.255.255/32',
'::1/128',
'fc00::/7',
'fe80::/10',
].map(cidr),
ru: [
'2.60.0.0/14',
'5.8.0.0/19',
'31.6.0.0/17',
'37.9.0.0/19',
'46.16.0.0/21',
'62.76.0.0/18',
'77.37.128.0/17',
'78.24.216.0/21',
'79.104.0.0/15',
'80.64.128.0/19',
'81.16.96.0/19',
'82.140.128.0/18',
'85.113.0.0/16',
'87.226.0.0/16',
'91.77.0.0/16',
'93.157.0.0/17',
'94.19.0.0/16',
'95.24.0.0/13',
'178.176.0.0/13',
'188.128.0.0/13',
'213.87.0.0/16',
'217.66.152.0/21',
'2a00:1148::/32',
].map(cidr),
telegram: [
'91.108.4.0/22',
'91.108.8.0/22',
'91.108.12.0/22',
'91.108.16.0/22',
'91.108.20.0/22',
'91.108.56.0/22',
'149.154.160.0/20',
'2001:67c:4e8::/48',
'2001:b28:f23d::/48',
'2001:b28:f23f::/48',
].map(cidr),
us: [
'3.0.0.0/9',
'12.0.0.0/8',
'23.192.0.0/11',
'34.192.0.0/10',
'50.16.0.0/14',
'52.0.0.0/10',
'63.64.0.0/11',
'65.0.0.0/10',
'68.32.0.0/11',
'71.0.0.0/11',
'96.0.0.0/9',
'128.0.0.0/10',
'199.0.0.0/12',
'208.64.0.0/12',
'2600:1f00::/24',
].map(cidr),
};
function categoriesOf(
entries: Record<string, GeoEntry[]>,
attributes: Record<string, string[]> = {},
): GeoCategory[] {
return Object.keys(entries)
.sort()
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
}
const SITE_CATEGORIES = categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES);
const IP_CATEGORIES = categoriesOf(IP_ENTRIES);
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
const GEOSITE_FILE: GeoFile = {
name: 'geosite.dat',
kind: 'site',
size: 4_812_544,
modifiedAt: UPDATED_AT,
categories: SITE_CATEGORIES.length,
};
const GEOIP_FILE: GeoFile = {
name: 'geoip.dat',
kind: 'ip',
size: 8_694_272,
modifiedAt: UPDATED_AT,
categories: IP_CATEGORIES.length,
};
const DAMAGED_FILE: GeoFile = {
name: 'geosite-custom.dat',
kind: 'site',
size: 262_144,
modifiedAt: Date.UTC(2026, 5, 2, 19, 45),
categories: 0,
error: 'proto: cannot parse invalid wire-format data',
};
const OVERSIZED_FILE: GeoFile = {
name: 'geoip-full.dat',
kind: 'ip',
size: 96_468_992,
modifiedAt: Date.UTC(2026, 6, 20, 8, 5),
categories: 0,
error: 'geodata file is too large to browse',
};
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
{
'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
};
function routesFor(files: GeoFile[]): GeoRoutes {
return {
'/panel/api/xray/geodata/files': () => files,
'/panel/api/xray/geodata/categories': (query) => {
const dataset = DATASETS[query.get('file') ?? ''];
const needle = (query.get('q') ?? '').trim().toLowerCase();
const items = (dataset?.categories ?? []).filter((category) =>
category.code.includes(needle),
);
return { total: items.length, items };
},
'/panel/api/xray/geodata/entries': (query) => {
const dataset = DATASETS[query.get('file') ?? ''];
const needle = (query.get('q') ?? '').trim().toLowerCase();
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
entry.value.toLowerCase().includes(needle),
);
const offset = Number(query.get('offset') ?? 0);
const limit = Number(query.get('limit') ?? 100);
return { total: matched.length, items: matched.slice(offset, offset + limit) };
},
};
}
function withFiles(files: GeoFile[]): Decorator {
const routes = routesFor(files);
return function GeodataBackend(Story) {
return (
<GeoApi routes={routes}>
<Story />
</GeoApi>
);
};
}
const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
function BrowserDemo(props: GeoBrowserModalProps) {
const [open, setOpen] = useState(props.open);
const [value, setValue] = useState(props.value);
const [synced, setSynced] = useState({ open: props.open, value: props.value });
if (synced.open !== props.open || synced.value !== props.value) {
setSynced({ open: props.open, value: props.value });
setOpen(props.open);
setValue(props.value);
}
return (
<Space orientation="vertical" size={12}>
<Space size={8}>
<Button onClick={() => setOpen(true)}>Open geo browser</Button>
<Typography.Text code>{value || 'no rule yet'}</Typography.Text>
</Space>
<GeoBrowserModal
{...props}
open={open}
value={value}
onApply={(next) => {
setValue(next);
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
</Space>
);
}
const meta = {
title: 'Geodata/GeoBrowserModal',
component: GeoBrowserModal,
tags: ['autodocs'],
parameters: {
layout: 'padded',
a11y: {
config: {
rules: [{ id: 'color-contrast', enabled: false }],
},
},
docs: {
description: {
component:
'Browser for the geosite/geoip `.dat` databases Xray resolves `geosite:` and `geoip:` routing tokens against: pick a database, search its categories, tick the ones a rule needs, and preview the domains or CIDRs inside the highlighted category. Applying merges the ticked categories back into the rule string, keeping hand-typed domains untouched. The stories serve `/panel/api/xray/geodata/*` from an in-memory fixture, so search, paging and selection all work without a panel backend.',
},
},
},
args: {
open: true,
kind: 'site',
value: '',
onApply: () => undefined,
onClose: () => undefined,
},
argTypes: {
open: { description: 'Whether the modal is visible.' },
kind: {
description:
'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
control: 'inline-radio',
options: ['site', 'ip'],
},
value: {
description:
'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
},
onApply: { description: 'Called with the merged rule string when Apply is pressed.' },
onClose: { description: 'Called when the modal is dismissed.' },
},
render: (args) => <BrowserDemo {...args} />,
} satisfies Meta<typeof GeoBrowserModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SiteDatabase: Story = {
decorators: [withDatabases],
args: { kind: 'site', value: 'geosite:google, geosite:telegram, ads.example.com' },
};
export const CategoryPreview: Story = {
decorators: [withDatabases],
args: { kind: 'site', value: 'geosite:google' },
parameters: {
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: false },
{ id: 'scrollable-region-focusable', enabled: false },
],
},
},
},
play: async ({ canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.type(await body.findByPlaceholderText('Search category'), 'telegram');
await userEvent.click(await body.findByText('telegram'));
await expect(await body.findByText('t.me')).toBeVisible();
},
};
export const IpDatabase: Story = {
decorators: [withDatabases],
args: { kind: 'ip', value: 'geoip:private, 10.0.0.0/8' },
};
export const NoDatabases: Story = {
decorators: [withFiles([])],
args: { kind: 'site', value: 'geosite:google' },
};
export const DamagedDatabase: Story = {
decorators: [withFiles([GEOSITE_FILE, DAMAGED_FILE, OVERSIZED_FILE])],
args: { kind: 'site', value: '' },
};
@@ -1,473 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Empty,
Input,
Modal,
Pagination,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata';
import { canonicalToken, mergeSelection, selectionFromValue, tokenFor } from '@/lib/xray/geoTokens';
import { SizeFormatter } from '@/utils';
import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types';
import './GeoBrowserModal.css';
const ENTRY_PAGE_SIZE = 100;
const CATEGORY_SCROLL_HEIGHT = 438;
const ENTRY_FILTER_DELAY = 500;
export interface GeoBrowserModalProps {
open: boolean;
kind: GeoKind;
value: string;
onApply: (value: string) => void;
onClose: () => void;
}
// A geosite category inside an ip rule (or the reverse) is a config Xray will
// reject, so a field only ever offers databases of its own kind.
function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] {
return files.filter(
(file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)),
);
}
function namePrefersKind(name: string, kind: GeoKind): boolean {
return name.toLowerCase().includes('ip') === (kind === 'ip');
}
function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined {
const usable = databasesFor(files, kind).filter((file) => !file.error);
const preferredName = kind === 'ip' ? 'geoip.dat' : 'geosite.dat';
return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name;
}
export default function GeoBrowserModal({
open,
kind,
value,
onApply,
onClose,
}: GeoBrowserModalProps) {
const { t } = useTranslation();
const [file, setFile] = useState<string | undefined>(undefined);
const [categoryQuery, setCategoryQuery] = useState('');
const [activeCode, setActiveCode] = useState<string | undefined>(undefined);
const [entryQuery, setEntryQuery] = useState('');
const [entryFilter, setEntryFilter] = useState('');
const [entryPage, setEntryPage] = useState(1);
const [selected, setSelected] = useState<string[]>([]);
const [known, setKnown] = useState<Set<string>>(() => new Set());
const [seededFiles, setSeededFiles] = useState<Set<string>>(() => new Set());
const filesQuery = useGeodataFiles(open);
const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
const activeFile = useMemo(
() => files.find((candidate) => candidate.name === file),
[files, file],
);
const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, kind]);
const categoriesQuery = useGeodataCategories(file, '', open && !!file);
// While a newly picked database loads, the query still serves the previous
// one's categories; seeding or filtering against those would attribute one
// database's codes to another.
const categoriesLoaded = !categoriesQuery.isPlaceholderData && !categoriesQuery.isLoading;
const categories = useMemo(
() => (categoriesLoaded ? (categoriesQuery.data?.items ?? []) : []),
[categoriesLoaded, categoriesQuery.data],
);
// Only the settled filter reaches the query key: every request rescans the
// whole .dat file server-side, so a per-keystroke fetch would be one full
// scan per character while the box itself stays instant.
const entriesQuery = useGeodataEntries(
file,
activeCode,
entryFilter,
(entryPage - 1) * ENTRY_PAGE_SIZE,
ENTRY_PAGE_SIZE,
open && !!file && !!activeCode,
);
// Resets clear both halves at once so a switch of database or category never
// renders with the previous filter still in the key, which would fire the
// very request the debounce exists to avoid.
const clearEntryFilter = useCallback(() => {
setEntryQuery('');
setEntryFilter('');
setEntryPage(1);
}, []);
useEffect(() => {
if (entryQuery === entryFilter) return;
const handle = window.setTimeout(() => {
setEntryFilter(entryQuery);
setEntryPage(1);
}, ENTRY_FILTER_DELAY);
return () => window.clearTimeout(handle);
}, [entryQuery, entryFilter]);
// Opening, picking the default database and seeding the selection are all
// render-time adjustments — an effect would paint the previous state first.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setKnown(new Set());
setSeededFiles(new Set());
setCategoryQuery('');
setEntryQuery('');
setEntryFilter('');
setActiveCode(undefined);
setEntryPage(1);
setSelected([]);
}
} else if (open && !file && files.length > 0) {
setFile(preferredFile(files, kind));
} else if (open && file && categories.length > 0 && !seededFiles.has(file)) {
const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
setKnown(new Set([...known, ...tokens]));
setSeededFiles(new Set(seededFiles).add(file));
const fromValue = selectionFromValue(value, new Set(tokens));
if (fromValue.length > 0) {
setSelected((previous) => [
...previous,
...fromValue.filter((token) => !previous.includes(token)),
]);
}
}
const visibleCategories = useMemo(() => {
const query = categoryQuery.trim().toLowerCase();
if (!query) return categories;
return categories.filter((category) => category.code.includes(query));
}, [categories, categoryQuery]);
// Comparisons run through the canonical form: a field may hold the long
// ext:geosite.dat:cn spelling or a different case, and those name the same
// category as the geosite:cn this modal generates.
const selectedCodes = useMemo(() => {
if (!file) return [];
const chosen = new Set(selected.map(canonicalToken));
return categories
.filter((category) => chosen.has(canonicalToken(tokenFor(file, category.code, fileKind))))
.map((category) => category.code);
}, [categories, file, fileKind, selected]);
const toggle = useCallback(
(codes: string[]) => {
if (!file) return;
const chosen = new Set(codes.map((code) => tokenFor(file, code, fileKind)));
const chosenCanonical = new Set([...chosen].map(canonicalToken));
// The table reports keys for the rows it currently shows, so a selection
// made before the search box was narrowed must survive untouched.
const shown = new Set(
visibleCategories.map((category) =>
canonicalToken(tokenFor(file, category.code, fileKind)),
),
);
setSelected((previous) => {
const kept = previous.filter((token) => {
const canonical = canonicalToken(token);
return !shown.has(canonical) || chosenCanonical.has(canonical);
});
const keptCanonical = new Set(kept.map(canonicalToken));
return [
...kept,
...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token))),
];
});
},
[visibleCategories, file, fileKind],
);
const categoryColumns: ColumnsType<GeoCategory> = useMemo(
() => [
{
title: t('pages.xray.geoBrowser.searchCategory'),
dataIndex: 'code',
render: (code: string, category: GeoCategory) => (
<span className="geo-category">
<span className="geo-code">{code}</span>
{category.attributes?.length > 0 && (
<span className="geo-attrs">
{category.attributes.map((attribute) => (
<Tag key={attribute} variant="filled">
@{attribute}
</Tag>
))}
</span>
)}
</span>
),
},
{
dataIndex: 'entries',
align: 'right',
width: 90,
render: (entries: number) => <span className="geo-count">{entries.toLocaleString()}</span>,
},
],
[t],
);
const entryColumns: ColumnsType<GeoEntry> = useMemo(
() => [
{
dataIndex: 'kind',
width: 88,
render: (entryKind: string) => (
<Tag variant="filled" className={`geo-kind geo-kind-${entryKind}`}>
{entryKind}
</Tag>
),
},
{
dataIndex: 'value',
render: (entryValue: string) => <span className="geo-entry-value">{entryValue}</span>,
},
],
[],
);
const fileOptions = files.map((candidate) => ({
value: candidate.name,
label: candidate.error
? `${candidate.name} — ${describeFileError(candidate.error, t)}`
: candidate.name,
disabled: !!candidate.error,
}));
const meta = activeFile
? t('pages.xray.geoBrowser.fileMeta', {
count: activeFile.categories.toLocaleString(),
size: SizeFormatter.sizeFormat(activeFile.size),
date: new Date(activeFile.modifiedAt).toLocaleString(),
})
: '';
const entriesTotal = entriesQuery.data?.total ?? 0;
const activeCategory = categories.find((category) => category.code === activeCode);
const countLabel = activeCategory
? t(
fileKind === 'ip'
? 'pages.xray.geoBrowser.subnetsCount'
: 'pages.xray.geoBrowser.entriesCount',
{
count: activeCategory.entries.toLocaleString(),
},
)
: '';
return (
<Modal
open={open}
title={t('pages.xray.geoBrowser.title')}
width={880}
onCancel={onClose}
onOk={() => onApply(mergeSelection(value, selected, known))}
okText={t('pages.xray.geoBrowser.apply')}
cancelText={t('close')}
className="geo-browser-modal"
>
{filesQuery.isError && (
<Alert
type="error"
showIcon
title={t('pages.xray.geoBrowser.loadFailed')}
className="mb-12"
/>
)}
{!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
<Empty
description={
<span>
{t('pages.xray.geoBrowser.noFiles')}
<br />
<Typography.Text type="secondary">
{t('pages.xray.geoBrowser.noFilesHint')}
</Typography.Text>
</span>
}
/>
) : (
<>
<div className="geo-toolbar">
<Select
value={file}
options={fileOptions}
onChange={(next) => {
setFile(next);
setActiveCode(undefined);
setCategoryQuery('');
clearEntryFilter();
}}
style={{ minWidth: 200 }}
aria-label={t('pages.xray.geoBrowser.database')}
/>
<Input.Search
value={categoryQuery}
onChange={(event) => setCategoryQuery(event.target.value)}
placeholder={t('pages.xray.geoBrowser.searchCategory')}
allowClear
/>
<Button
onClick={() =>
toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])
}
disabled={visibleCategories.length === 0}
>
{`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
</Button>
<span className="geo-meta">{meta}</span>
</div>
<div className="geo-columns">
<div className="geo-panel geo-categories">
<Table
size="small"
virtual
showHeader={false}
rowKey="code"
columns={categoryColumns}
dataSource={visibleCategories}
loading={
filesQuery.isLoading ||
categoriesQuery.isLoading ||
categoriesQuery.isPlaceholderData
}
pagination={false}
scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
rowSelection={{
columnWidth: 42,
preserveSelectedRowKeys: true,
selectedRowKeys: selectedCodes,
onChange: (keys) => toggle(keys as string[]),
}}
onRow={(category) => ({
onClick: (event) => {
if ((event.target as HTMLElement).closest('.ant-table-selection-column'))
return;
setActiveCode(category.code);
clearEntryFilter();
},
})}
rowClassName={(category) => (category.code === activeCode ? 'geo-row-active' : '')}
/>
</div>
<div className="geo-panel geo-preview">
{activeCode ? (
<>
<div className="geo-preview-head">
<Tooltip title={file ? tokenFor(file, activeCode, fileKind) : activeCode}>
<span className="geo-preview-title">{activeCode}</span>
</Tooltip>
<Typography.Text type="secondary">{countLabel}</Typography.Text>
<Input
value={entryQuery}
onChange={(event) => setEntryQuery(event.target.value)}
placeholder={t('pages.xray.geoBrowser.searchEntries')}
allowClear
className="geo-entry-filter"
/>
</div>
<div className="geo-preview-body">
<Table
size="small"
showHeader={false}
rowKey={(entry, index) => `${entry.value}-${index}`}
columns={entryColumns}
dataSource={entriesQuery.data?.items ?? []}
loading={entriesQuery.isLoading}
locale={{
emptyText: entriesQuery.isError
? t('pages.xray.geoBrowser.loadFailed')
: t('pages.xray.geoBrowser.noMatches'),
}}
pagination={false}
/>
</div>
<div className="geo-pager">
<Pagination
current={entryPage}
pageSize={ENTRY_PAGE_SIZE}
total={entriesTotal}
size="small"
showSizeChanger={false}
onChange={setEntryPage}
showTotal={(total, range) =>
t('pages.xray.geoBrowser.shownRange', {
from: range[0].toLocaleString(),
to: range[1].toLocaleString(),
total: total.toLocaleString(),
})
}
/>
</div>
</>
) : (
<div className="geo-placeholder">
<Typography.Text type="secondary">
{t('pages.xray.geoBrowser.pickCategory')}
</Typography.Text>
</div>
)}
</div>
</div>
<div className="geo-footer">
{selected.length === 0 ? (
<Typography.Text type="secondary">
{t('pages.xray.geoBrowser.emptySelection')}
</Typography.Text>
) : (
<>
<Space size={4} wrap className="geo-chips">
{selected.map((token) => (
<Tag
key={token}
closable
color="processing"
onClose={() =>
setSelected((previous) => previous.filter((item) => item !== token))
}
>
{token}
</Tag>
))}
</Space>
<span className="geo-selected-count">
{t('pages.xray.geoBrowser.selected', { count: selected.length })}
</span>
<Button type="link" size="small" onClick={() => setSelected([])}>
{t('pages.xray.geoBrowser.clearAll')}
</Button>
</>
)}
</div>
</>
)}
</Modal>
);
}
function describeFileError(error: string, t: (key: string) => string): string {
if (error.includes('too large')) return t('pages.xray.geoBrowser.tooLarge');
return t('pages.xray.geoBrowser.parseFailed');
}
@@ -1,296 +0,0 @@
import { useEffect, useState, type ReactNode } from 'react';
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { expect, within } from 'storybook/test';
import { Space } from 'antd';
import { parseTokens } from '@/lib/xray/geoTokens';
import type { GeoCategory, GeoEntry, GeoFile, GeodataTokenIssue } from '@/generated/types';
import GeoTokenInput, { type GeoTokenInputProps } from './GeoTokenInput';
type GeoResponder = (query: URLSearchParams, body: URLSearchParams) => unknown;
type GeoRoutes = Record<string, GeoResponder>;
const realFetch = window.fetch.bind(window);
let activeRoutes: GeoRoutes = {};
function requestUrl(input: RequestInfo | URL): URL {
if (typeof input === 'string') return new URL(input, window.location.origin);
if (input instanceof URL) return input;
return new URL(input.url, window.location.origin);
}
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = requestUrl(input);
const responder = activeRoutes[url.pathname];
if (!responder) return realFetch(input, init);
const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '');
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams, form) });
return Promise.resolve(
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
);
}
function activate(routes: GeoRoutes): void {
activeRoutes = routes;
window.fetch = geoFetch;
}
function deactivate(routes: GeoRoutes): void {
if (activeRoutes === routes) activeRoutes = {};
}
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
const [client] = useState(() => {
activate(routes);
return new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
});
useEffect(() => {
activate(routes);
return () => deactivate(routes);
}, [routes]);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
'category-ads-all': [
domain('doubleclick.net'),
domain('googleadservices.com'),
domain('googlesyndication.com'),
domain('criteo.com'),
domain('taboola.com'),
domain('outbrain.com'),
],
cn: [
domain('baidu.com'),
domain('qq.com'),
domain('taobao.com'),
domain('weibo.com'),
domain('bilibili.com'),
],
google: [
domain('google.com'),
domain('googleapis.com'),
domain('gstatic.com'),
domain('googleusercontent.com'),
domain('ggpht.com'),
domain('android.com'),
],
netflix: [
domain('netflix.com'),
domain('nflximg.net'),
domain('nflxvideo.net'),
domain('fast.com'),
],
telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')],
youtube: [
domain('youtube.com'),
domain('youtu.be'),
domain('ytimg.com'),
domain('googlevideo.com'),
],
};
const IP_ENTRIES: Record<string, GeoEntry[]> = {
cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr),
cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr),
private: [
'10.0.0.0/8',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.168.0.0/16',
'::1/128',
'fc00::/7',
'fe80::/10',
].map(cidr),
telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr),
};
const SITE_ATTRIBUTES: Record<string, string[]> = {
google: ['ads', 'cn'],
youtube: ['ads'],
};
function categoriesOf(
entries: Record<string, GeoEntry[]>,
attributes: Record<string, string[]> = {},
): GeoCategory[] {
return Object.keys(entries)
.sort()
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
}
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
{
'geosite.dat': {
categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES),
entries: SITE_ENTRIES,
},
'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
};
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
const FILES: GeoFile[] = [
{
name: 'geosite.dat',
kind: 'site',
size: 4_812_544,
modifiedAt: UPDATED_AT,
categories: DATASETS['geosite.dat'].categories.length,
},
{
name: 'geoip.dat',
kind: 'ip',
size: 8_694_272,
modifiedAt: UPDATED_AT,
categories: DATASETS['geoip.dat'].categories.length,
},
];
function referenceOf(token: string, isIP: boolean): { file: string; code: string } | null {
const [prefix, ...rest] = token.split(':');
const code = (value: string) => value.split('@')[0].toLowerCase();
if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) };
if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) };
if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) };
return isIP && prefix === 'ext-ip'
? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) }
: null;
}
function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] {
const issues: GeodataTokenIssue[] = [];
for (const token of tokens) {
const reference = referenceOf(token, isIP);
if (!reference) continue;
const dataset = DATASETS[reference.file];
if (!dataset) {
issues.push({ token, reason: 'fileMissing', file: reference.file, code: reference.code });
continue;
}
if (!dataset.categories.some((category) => category.code === reference.code)) {
issues.push({ token, reason: 'categoryMissing', file: reference.file, code: reference.code });
}
}
return issues;
}
const routes: GeoRoutes = {
'/csrf-token': () => 'storybook-csrf-token',
'/panel/api/xray/geodata/files': () => FILES,
'/panel/api/xray/geodata/categories': (query) => {
const dataset = DATASETS[query.get('file') ?? ''];
const needle = (query.get('q') ?? '').trim().toLowerCase();
const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
return { total: items.length, items };
},
'/panel/api/xray/geodata/entries': (query) => {
const dataset = DATASETS[query.get('file') ?? ''];
const needle = (query.get('q') ?? '').trim().toLowerCase();
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
entry.value.toLowerCase().includes(needle),
);
const offset = Number(query.get('offset') ?? 0);
const limit = Number(query.get('limit') ?? 100);
return { total: matched.length, items: matched.slice(offset, offset + limit) };
},
'/panel/api/xray/geodata/validate': (_query, form) =>
validate(parseTokens(form.get('tokens') ?? ''), form.get('kind') === 'ip'),
};
const withGeodata: Decorator = function GeodataBackend(Story) {
return (
<GeoApi routes={routes}>
<Story />
</GeoApi>
);
};
function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
const [current, setCurrent] = useState(value);
const [synced, setSynced] = useState(value);
if (synced !== value) {
setSynced(value);
setCurrent(value);
}
return (
<Space orientation="vertical" size={4} style={{ width: 460 }}>
<label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
<GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
</Space>
);
}
const meta = {
title: 'Geodata/GeoTokenInput',
component: GeoTokenInput,
tags: ['autodocs'],
parameters: {
layout: 'padded',
a11y: {
config: {
rules: [{ id: 'color-contrast', enabled: false }],
},
},
docs: {
description: {
component:
'Routing rule field for the xray rule editor: a comma separated list of domains/CIDRs and `geosite:` / `geoip:` tokens, with a database button in the addon that opens the geo category browser. Typed tokens are validated against the databases on disk after a short pause, and anything the running core would not resolve is called out under the field. The stories answer `/panel/api/xray/geodata/*` from an in-memory fixture, so validation and the browser both work without a panel backend.',
},
},
},
decorators: [withGeodata],
args: { kind: 'domain' },
argTypes: {
value: { description: 'Comma separated rule string held by the parent form.' },
onChange: {
description: 'Called with the full rule string on every edit and on Apply from the browser.',
},
onBlur: {
description: 'Forwarded to the input; used by React Hook Form to mark the field touched.',
},
kind: {
description:
'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
control: 'inline-radio',
options: ['domain', 'ip'],
},
placeholder: { description: 'Placeholder shown while the field is empty.' },
id: { description: 'Input id, linked to the label rendered by the surrounding form field.' },
},
render: (args) => <ControlledTokenInput {...args} />,
} satisfies Meta<typeof GeoTokenInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {
args: { kind: 'domain', value: '', placeholder: 'geosite:google, example.com' },
};
export const DomainTokens: Story = {
args: { kind: 'domain', value: 'geosite:google, google.com' },
};
export const IpTokens: Story = {
args: { kind: 'ip', value: 'geoip:private' },
};
export const UnknownCategory: Story = {
args: { kind: 'domain', value: 'geosite:blabla, geosite:google' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 }),
).toBeVisible();
},
};
@@ -1,140 +0,0 @@
import { useEffect, useState } from 'react';
import type { Ref } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Space, Tooltip, Typography } from 'antd';
import type { InputRef } from 'antd';
import { DatabaseOutlined } from '@ant-design/icons';
import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata';
import { parseTokens } from '@/lib/xray/geoTokens';
import type { GeodataTokenIssue, GeoKind } from '@/generated/types';
import GeoBrowserModal from './GeoBrowserModal';
const VALIDATION_DELAY = 600;
// Each reason needs its own wording: a missing database is fixed under Geodata,
// a missing category by picking another one, and a bad token by editing it.
const REASON_KEYS: Record<string, string> = {
fileMissing: 'pages.xray.geoBrowser.missingDatabase',
categoryMissing: 'pages.xray.geoBrowser.unknownCategories',
attributeMissing: 'pages.xray.geoBrowser.unknownAttribute',
syntax: 'pages.xray.geoBrowser.invalidToken',
wrongKind: 'pages.xray.geoBrowser.wrongKind',
};
export interface GeoTokenInputProps {
value?: string;
onChange?: (value: string) => void;
onBlur?: () => void;
kind: GeoTokenKind;
placeholder?: string;
id?: string;
ref?: Ref<InputRef>;
}
export default function GeoTokenInput({
value = '',
onChange,
onBlur,
kind,
placeholder,
id,
ref,
}: GeoTokenInputProps) {
const { t } = useTranslation();
const [browsing, setBrowsing] = useState(false);
const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
const [checkFailed, setCheckFailed] = useState(false);
const validate = useValidateGeoTokens();
const { mutateAsync } = validate;
// An empty field has nothing to validate, so it clears during render rather
// than waiting a commit for the effect to catch up.
const isEmpty = parseTokens(value).length === 0;
const [wasEmpty, setWasEmpty] = useState(isEmpty);
if (isEmpty !== wasEmpty) {
setWasEmpty(isEmpty);
if (isEmpty) {
setIssues([]);
setCheckFailed(false);
}
}
useEffect(() => {
const tokens = parseTokens(value);
if (tokens.length === 0) return;
let cancelled = false;
const timer = setTimeout(() => {
mutateAsync({ tokens, kind })
.then((found) => {
if (cancelled) return;
setIssues(found);
setCheckFailed(false);
})
// A rejected check says nothing about the tokens, so the warnings are
// dropped but replaced by a notice — silence here reads as "all valid".
.catch(() => {
if (cancelled) return;
setIssues([]);
setCheckFailed(true);
});
}, VALIDATION_DELAY);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [value, kind, mutateAsync]);
return (
<>
<Space.Compact block>
<Input
ref={ref}
id={id}
value={value}
placeholder={placeholder}
onChange={(event) => onChange?.(event.target.value)}
onBlur={onBlur}
/>
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
<Button
icon={<DatabaseOutlined />}
aria-label={t('pages.xray.geoBrowser.openTooltip')}
onClick={() => setBrowsing(true)}
/>
</Tooltip>
</Space.Compact>
{groupByReason(issues).map(([reason, tokens]) => (
<Typography.Text key={reason} type="warning" className="geo-unknown-hint">
{t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
</Typography.Text>
))}
{checkFailed && (
<Typography.Text type="secondary" className="geo-unknown-hint">
{t('pages.xray.geoBrowser.checkFailed')}
</Typography.Text>
)}
<GeoBrowserModal
open={browsing}
kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
value={value}
onApply={(next) => {
onChange?.(next);
setBrowsing(false);
}}
onClose={() => setBrowsing(false)}
/>
</>
);
}
function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
const grouped = new Map<string, string[]>();
for (const issue of issues) {
const tokens = grouped.get(issue.reason) ?? [];
tokens.push(issue.token);
grouped.set(issue.reason, tokens);
}
return [...grouped];
}
-4
View File
@@ -1,4 +0,0 @@
export { default as GeoBrowserModal } from './GeoBrowserModal';
export type { GeoBrowserModalProps } from './GeoBrowserModal';
export { default as GeoTokenInput } from './GeoTokenInput';
export type { GeoTokenInputProps } from './GeoTokenInput';
@@ -1,90 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AllSetting } from '@/models/setting';
import { EmailNotifications } from './EmailNotifications';
const meta = {
title: 'UI/Notifications/EmailNotifications',
component: EmailNotifications,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grid of grouped event checkboxes on the settings page that picks which panel events (outbound/node health, Xray crashes, CPU/RAM thresholds, login attempts) trigger an SMTP email, stored as a comma-separated list in smtpEnabledEvents.',
},
},
},
argTypes: {
allSetting: {
description:
'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
},
updateSetting: {
description:
'Receives a partial settings patch when an event is toggled or a threshold input changes.',
},
},
} satisfies Meta<typeof EmailNotifications>;
export default meta;
type Story = StoryObj<typeof meta>;
function StatefulDemo({ initial }: { initial: AllSetting }) {
const [settings, setSettings] = useState(initial);
return (
<EmailNotifications
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
const placeholderArgs = {
allSetting: new AllSetting(),
updateSetting: () => undefined,
};
export const NothingSelected: Story = {
args: placeholderArgs,
render: () => <StatefulDemo initial={new AllSetting()} />,
};
export const SystemThresholdAlerts: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={
new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })
}
/>
),
};
export const InfrastructureOnly: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })}
/>
),
};
export const AllEventsEnabled: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={
new AllSetting({
smtpEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
smtpCpu: 80,
smtpMemory: 80,
})
}
/>
),
};
@@ -1,138 +0,0 @@
import { InputNumber } from 'antd';
import {
CloudServerOutlined,
ThunderboltOutlined,
DesktopOutlined,
DashboardOutlined,
SafetyOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { NotificationLayout } from './NotificationLayout';
import { NotificationGroup } from './NotificationGroup';
import type { NotificationGroupConfig } from './types';
const GROUPS: NotificationGroupConfig[] = [
{
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
events: [
{
key: 'outbound.down',
label: 'eventOutboundDown',
settingKey: 'outboundDownThreshold',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber
size="small"
min={1}
max={100}
value={value}
onChange={onChange}
aria-label={ariaLabel}
style={{ width: 80 }}
/>
),
},
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
],
},
{
icon: <ThunderboltOutlined />,
title: 'eventGroupXray',
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
},
{
icon: <DesktopOutlined />,
title: 'eventGroupNode',
events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
],
},
{
icon: <DashboardOutlined />,
title: 'eventGroupSystem',
events: [
{
key: 'cpu.high',
label: 'eventCPUHigh',
settingKey: 'smtpCpu',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber
size="small"
min={0}
max={100}
value={value}
onChange={onChange}
aria-label={ariaLabel}
style={{ width: 80 }}
/>
),
},
{
key: 'memory.high',
label: 'eventMemoryHigh',
settingKey: 'smtpMemory',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber
size="small"
min={0}
max={100}
value={value}
onChange={onChange}
aria-label={ariaLabel}
style={{ width: 80 }}
/>
),
},
],
},
{
icon: <SafetyOutlined />,
title: 'eventGroupSecurity',
events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
},
];
interface Props {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export function EmailNotifications({ allSetting, updateSetting }: Props) {
const events = allSetting.smtpEnabledEvents || '';
const selected = events
? events
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
function toggle(key: string) {
const next = selected.includes(key) ? selected.filter((e) => e !== key) : [...selected, key];
updateSetting({ smtpEnabledEvents: next.join(',') });
}
function toggleAll(keys: string[]) {
const allSelected = keys.every((v) => selected.includes(v));
const next = allSelected
? selected.filter((v) => !keys.includes(v))
: [...new Set([...selected, ...keys])];
updateSetting({ smtpEnabledEvents: next.join(',') });
}
return (
<NotificationLayout>
{GROUPS.map((group, i) => (
<NotificationGroup
key={i}
config={group}
selected={selected}
onToggle={toggle}
onToggleAll={toggleAll}
allSetting={allSetting}
updateSetting={updateSetting}
/>
))}
</NotificationLayout>
);
}
@@ -61,7 +61,7 @@ export const Unchecked: Story = {
export const Checked: Story = {
args: {
label: 'pages.settings.eventXrayCrash',
label: 'pages.settings.eventNodeDown',
checked: true,
onToggle: () => undefined,
},
@@ -1,7 +1,7 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber } from 'antd';
import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
import { DashboardOutlined, DesktopOutlined } from '@ant-design/icons';
import { AllSetting } from '@/models/setting';
import { NotificationGroup } from './NotificationGroup';
@@ -46,12 +46,12 @@ const systemGroup: NotificationGroupConfig = {
],
};
const outboundGroup: NotificationGroupConfig = {
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
const nodeGroup: NotificationGroupConfig = {
icon: <DesktopOutlined />,
title: 'eventGroupNode',
events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
],
};
@@ -64,7 +64,7 @@ const meta = {
docs: {
description: {
component:
'Card for one notification event group (outbound, Xray, node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
'Card for one notification event group (node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
},
},
},
@@ -138,7 +138,7 @@ export const PartiallySelected: Story = {
export const NoneSelected: Story = {
args: {
config: outboundGroup,
config: nodeGroup,
selected: [],
onToggle: () => undefined,
onToggleAll: () => undefined,
@@ -61,7 +61,7 @@ export const TypicalMonitoring: Story = {
new AllSetting({
tgBotEnable: true,
tgBotChatId: '123456789',
tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
tgEnabledEvents: 'node.down,cpu.high,memory.high,login.attempt',
tgCpu: 85,
tgMemory: 90,
})
@@ -77,8 +77,7 @@ export const EverythingEnabled: Story = {
initial={
new AllSetting({
tgBotEnable: true,
tgEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
tgEnabledEvents: 'node.down,node.up,cpu.high,memory.high,login.attempt',
tgCpu: 70,
tgMemory: 75,
})
@@ -1,45 +1,11 @@
import { InputNumber } from 'antd';
import {
CloudServerOutlined,
ThunderboltOutlined,
DesktopOutlined,
DashboardOutlined,
SafetyOutlined,
} from '@ant-design/icons';
import { DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { NotificationLayout } from './NotificationLayout';
import { NotificationGroup } from './NotificationGroup';
import type { NotificationGroupConfig } from './types';
const GROUPS: NotificationGroupConfig[] = [
{
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
events: [
{
key: 'outbound.down',
label: 'eventOutboundDown',
settingKey: 'outboundDownThreshold',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber
size="small"
min={1}
max={100}
value={value}
onChange={onChange}
aria-label={ariaLabel}
style={{ width: 80 }}
/>
),
},
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
],
},
{
icon: <ThunderboltOutlined />,
title: 'eventGroupXray',
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
},
{
icon: <DesktopOutlined />,
title: 'eventGroupNode',
+1 -3
View File
@@ -12,12 +12,10 @@ interface SubPageData {
expire?: string | number;
lastOnline?: string | number;
subUrl?: string;
subJsonUrl?: string;
subClashUrl?: string;
subTitle?: string;
links?: string[];
emails?: string[];
datepicker?: 'gregorian' | 'jalalian';
datepicker?: 'gregorian';
announce?: string;
downloadByte?: string | number;
uploadByte?: string | number;
+25 -181
View File
@@ -3,83 +3,23 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSetting": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"ipLimitAllowlist": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInsecureSkipVerify": false,
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"outboundDownThreshold": 1,
"pageSize": 0,
"panelOutbound": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subPort": 0,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
@@ -96,9 +36,8 @@ export const EXAMPLES: Record<string, unknown> = {
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trafficStatsSwitches": "",
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "",
"webCertFile": "",
@@ -110,90 +49,27 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSettingView": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false,
"hasSmtpPassword": false,
"hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false,
"ipLimitAllowlist": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInsecureSkipVerify": false,
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"outboundDownThreshold": 1,
"pageSize": 0,
"panelOutbound": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subPort": 0,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
@@ -210,9 +86,8 @@ export const EXAMPLES: Record<string, unknown> = {
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trafficStatsSwitches": "",
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "",
"webCertFile": "",
@@ -221,26 +96,6 @@ export const EXAMPLES: Record<string, unknown> = {
"webListen": "",
"webPort": 1
},
"AmneziaWGLogs": {
"events": [
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
],
"peers": [
{
"allowedIPs": "10.8.1.2/32",
"down": 4194304,
"email": "peer@example.com",
"endpoint": "203.0.113.9:51820",
"handshake": 1735732800000,
"inboundId": 1,
"interface": "awg1",
"online": true,
"tag": "inbound-51820",
"up": 1048576
}
],
"running": true
},
"ApiToken": {
"createdAt": 0,
"enabled": false,
@@ -401,12 +256,6 @@ export const EXAMPLES: Record<string, unknown> = {
"name": "geosite.dat",
"size": 1467392
},
"GeodataTokenIssue": {
"code": "blabla",
"file": "geosite.dat",
"reason": "categoryMissing",
"token": "geosite:blabla"
},
"HistoryOfSeeders": {
"id": 0,
"seederName": ""
@@ -530,6 +379,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": true,
"expiryTime": 0,
"fallbackParent": null,
"forwarding": "",
"id": 1,
"lastTrafficResetTime": 0,
"listen": "",
@@ -648,16 +498,23 @@ export const EXAMPLES: Record<string, unknown> = {
""
],
"name": "",
"nodeType": "traditional",
"outboundTag": "",
"pinnedCertSha256": "",
"port": 1,
"port": 0,
"remark": "",
"scheme": "http",
"tlsVerifyMode": "verify"
},
"NodeView": {
"activeCount": 20,
"activeEmails": [
"a@example.com"
],
"address": "node.example.com",
"agentUpdateError": "",
"agentUpdateState": "failed",
"agentUpdateTarget": "A1.0.1",
"allowPrivateAddress": false,
"basePath": "/",
"clientCount": 25,
@@ -666,7 +523,13 @@ export const EXAMPLES: Record<string, unknown> = {
"cpuPct": 12.5,
"createdAt": 1700000000,
"depletedCount": 1,
"depletedEmails": [
"c@example.com"
],
"disabledCount": 2,
"disabledEmails": [
"b@example.com"
],
"enable": true,
"guid": "node-guid",
"hasApiToken": true,
@@ -676,6 +539,8 @@ export const EXAMPLES: Record<string, unknown> = {
"inboundTags": [
"in-443-tcp"
],
"ipv4": "203.0.113.7",
"ipv6": "2001:db8::7",
"lastError": "",
"lastHeartbeat": 1700000000,
"latencyMs": 42,
@@ -683,7 +548,11 @@ export const EXAMPLES: Record<string, unknown> = {
"name": "edge-1",
"netDown": 1048576,
"netUp": 2097152,
"nodeType": "traditional",
"onlineCount": 5,
"onlineEmails": [
"a@example.com"
],
"outboundTag": "direct",
"panelVersion": "v3.x.x",
"parentGuid": "",
@@ -713,18 +582,6 @@ export const EXAMPLES: Record<string, unknown> = {
"runId": "1735689600123456789",
"state": "success"
},
"PeerActivity": {
"allowedIPs": "10.8.1.2/32",
"down": 4194304,
"email": "peer@example.com",
"endpoint": "203.0.113.9:51820",
"handshake": 1735732800000,
"inboundId": 1,
"interface": "awg1",
"online": true,
"tag": "inbound-51820",
"up": 1048576
},
"ProbeResultUI": {
"cpuPct": 12.5,
"error": "",
@@ -805,19 +662,6 @@ export const EXAMPLES: Record<string, unknown> = {
"key": "",
"value": ""
},
"SubBalancer": {
"createdAt": 1710000000000,
"enabled": true,
"id": 1,
"inboundIds": [
1,
3
],
"remark": "auto-fastest",
"sortOrder": 1,
"strategy": "random",
"updatedAt": 1710000000000
},
"User": {
"id": 0,
"password": "",
File diff suppressed because it is too large Load Diff
+15 -164
View File
@@ -4,6 +4,7 @@ export type OnlineAPISupport = number;
export type ProcessState = string;
export type Protocol = string;
export type SubLinkProvider = unknown;
export type rawBytes = number[];
export type staticEgressResolver = string;
export type trafficLocalApplyAction = number;
export type transportBits = number;
@@ -11,83 +12,23 @@ export type transportBits = number;
export interface AllSetting {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
ipLimitAllowlist: string;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInsecureSkipVerify: boolean;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
outboundDownThreshold: number;
pageSize: number;
panelOutbound: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
subListen: string;
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
@@ -104,9 +45,8 @@ export interface AllSetting {
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trafficStatsSwitches: string;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string;
webCertFile: string;
@@ -119,90 +59,27 @@ export interface AllSetting {
export interface AllSettingView {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
hasApiToken: boolean;
hasLdapPassword: boolean;
hasNordSecret: boolean;
hasSmtpPassword: boolean;
hasTgBotToken: boolean;
hasTwoFactorToken: boolean;
hasWarpSecret: boolean;
ipLimitAllowlist: string;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInsecureSkipVerify: boolean;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
outboundDownThreshold: number;
pageSize: number;
panelOutbound: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
subListen: string;
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
@@ -219,9 +96,8 @@ export interface AllSettingView {
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trafficStatsSwitches: string;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string;
webCertFile: string;
@@ -231,12 +107,6 @@ export interface AllSettingView {
webPort: number;
}
export interface AmneziaWGLogs {
events: string[];
peers: PeerActivity[];
running: boolean;
}
export interface ApiToken {
createdAt: number;
enabled: boolean;
@@ -391,13 +261,6 @@ export interface GeoFile {
size: number;
}
export interface GeodataTokenIssue {
code?: string;
file?: string;
reason: string;
token: string;
}
export interface HistoryOfSeeders {
id: number;
seederName: string;
@@ -481,6 +344,7 @@ export interface Inbound {
enable: boolean;
expiryTime: number;
fallbackParent?: FallbackParentInfo | null;
forwarding: string;
id: number;
lastTrafficResetTime: number;
listen: string;
@@ -601,6 +465,7 @@ export interface NodeMutationRequest {
inboundSyncMode: string;
inboundTags: string[];
name: string;
nodeType?: string;
outboundTag: string;
pinnedCertSha256: string;
port: number;
@@ -611,7 +476,11 @@ export interface NodeMutationRequest {
export interface NodeView {
activeCount: number;
activeEmails?: string[];
address: string;
agentUpdateError?: string;
agentUpdateState?: string;
agentUpdateTarget?: string;
allowPrivateAddress: boolean;
basePath: string;
clientCount: number;
@@ -620,7 +489,9 @@ export interface NodeView {
cpuPct: number;
createdAt: number;
depletedCount: number;
depletedEmails?: string[];
disabledCount: number;
disabledEmails?: string[];
enable: boolean;
guid: string;
hasApiToken: boolean;
@@ -628,6 +499,8 @@ export interface NodeView {
inboundCount: number;
inboundSyncMode: string;
inboundTags: string[];
ipv4?: string;
ipv6?: string;
lastError: string;
lastHeartbeat: number;
latencyMs: number;
@@ -635,7 +508,9 @@ export interface NodeView {
name: string;
netDown: number;
netUp: number;
nodeType?: string;
onlineCount: number;
onlineEmails?: string[];
outboundTag: string;
panelVersion: string;
parentGuid?: string;
@@ -668,19 +543,6 @@ export interface PanelUpdateStatus {
state: string;
}
export interface PeerActivity {
allowedIPs: string;
down: number;
email: string;
endpoint: string;
handshake: number;
inboundId: number;
interface: string;
online: boolean;
tag: string;
up: number;
}
export interface ProbeResultUI {
cpuPct: number;
error: string;
@@ -763,17 +625,6 @@ export interface Setting {
value: string;
}
export interface SubBalancer {
createdAt: number;
enabled: boolean;
id: number;
inboundIds: number[];
remark: string;
sortOrder: number;
strategy: string;
updatedAt: number;
}
export interface User {
id: number;
password: string;
+20 -171
View File
@@ -15,6 +15,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const rawBytesSchema = z.array(z.number().int());
export type rawBytes = z.infer<typeof rawBytesSchema>;
export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
@@ -27,83 +30,23 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
export const AllSettingSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
ipLimitAllowlist: z.string(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInsecureSkipVerify: z.boolean(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(),
remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(1).max(65535),
subPort: z.number().int().min(0).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
@@ -120,9 +63,8 @@ export const AllSettingSchema = z.object({
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trafficStatsSwitches: z.string(),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
webCertFile: z.string(),
@@ -136,90 +78,27 @@ export type AllSetting = z.infer<typeof AllSettingSchema>;
export const AllSettingViewSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
hasApiToken: z.boolean(),
hasLdapPassword: z.boolean(),
hasNordSecret: z.boolean(),
hasSmtpPassword: z.boolean(),
hasTgBotToken: z.boolean(),
hasTwoFactorToken: z.boolean(),
hasWarpSecret: z.boolean(),
ipLimitAllowlist: z.string(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInsecureSkipVerify: z.boolean(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(),
remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(1).max(65535),
subPort: z.number().int().min(0).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
@@ -236,9 +115,8 @@ export const AllSettingViewSchema = z.object({
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trafficStatsSwitches: z.string(),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
webCertFile: z.string(),
@@ -249,13 +127,6 @@ export const AllSettingViewSchema = z.object({
});
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
export const AmneziaWGLogsSchema = z.object({
events: z.array(z.string()),
peers: z.array(z.lazy(() => PeerActivitySchema)),
running: z.boolean(),
});
export type AmneziaWGLogs = z.infer<typeof AmneziaWGLogsSchema>;
export const ApiTokenSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
@@ -423,14 +294,6 @@ export const GeoFileSchema = z.object({
});
export type GeoFile = z.infer<typeof GeoFileSchema>;
export const GeodataTokenIssueSchema = z.object({
code: z.string().optional(),
file: z.string().optional(),
reason: z.string(),
token: z.string(),
});
export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
export const HistoryOfSeedersSchema = z.object({
id: z.number().int(),
seederName: z.string(),
@@ -517,6 +380,7 @@ export const InboundSchema = z.object({
enable: z.boolean(),
expiryTime: z.number().int(),
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
forwarding: z.string(),
id: z.number().int(),
lastTrafficResetTime: z.number().int(),
listen: z.string(),
@@ -643,9 +507,10 @@ export const NodeMutationRequestSchema = z.object({
inboundSyncMode: z.enum(['all', 'selected']),
inboundTags: z.array(z.string()),
name: z.string(),
nodeType: z.enum(['traditional', 'agent']).optional(),
outboundTag: z.string(),
pinnedCertSha256: z.string(),
port: z.number().int().min(1).max(65535),
port: z.number().int().min(0).max(65535),
remark: z.string(),
scheme: z.enum(['http', 'https']),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
@@ -654,7 +519,11 @@ export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
export const NodeViewSchema = z.object({
activeCount: z.number().int(),
activeEmails: z.array(z.string()).optional(),
address: z.string(),
agentUpdateError: z.string().optional(),
agentUpdateState: z.string().optional(),
agentUpdateTarget: z.string().optional(),
allowPrivateAddress: z.boolean(),
basePath: z.string(),
clientCount: z.number().int(),
@@ -663,7 +532,9 @@ export const NodeViewSchema = z.object({
cpuPct: z.number(),
createdAt: z.number().int(),
depletedCount: z.number().int(),
depletedEmails: z.array(z.string()).optional(),
disabledCount: z.number().int(),
disabledEmails: z.array(z.string()).optional(),
enable: z.boolean(),
guid: z.string(),
hasApiToken: z.boolean(),
@@ -671,6 +542,8 @@ export const NodeViewSchema = z.object({
inboundCount: z.number().int(),
inboundSyncMode: z.string(),
inboundTags: z.array(z.string()),
ipv4: z.string().optional(),
ipv6: z.string().optional(),
lastError: z.string(),
lastHeartbeat: z.number().int(),
latencyMs: z.number().int(),
@@ -678,7 +551,9 @@ export const NodeViewSchema = z.object({
name: z.string(),
netDown: z.number().int(),
netUp: z.number().int(),
nodeType: z.string().optional(),
onlineCount: z.number().int(),
onlineEmails: z.array(z.string()).optional(),
outboundTag: z.string(),
panelVersion: z.string(),
parentGuid: z.string().optional(),
@@ -714,20 +589,6 @@ export const PanelUpdateStatusSchema = z.object({
});
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
export const PeerActivitySchema = z.object({
allowedIPs: z.string(),
down: z.number().int(),
email: z.string(),
endpoint: z.string(),
handshake: z.number().int(),
inboundId: z.number().int(),
interface: z.string(),
online: z.boolean(),
tag: z.string(),
up: z.number().int(),
});
export type PeerActivity = z.infer<typeof PeerActivitySchema>;
export const ProbeResultUISchema = z.object({
cpuPct: z.number(),
error: z.string(),
@@ -814,18 +675,6 @@ export const SettingSchema = z.object({
});
export type Setting = z.infer<typeof SettingSchema>;
export const SubBalancerSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
id: z.number().int(),
inboundIds: z.array(z.number().int()),
remark: z.string().max(256),
sortOrder: z.number().int().min(1),
strategy: z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']),
updatedAt: z.number().int(),
});
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
export const UserSchema = z.object({
id: z.number().int(),
password: z.string(),
+2 -52
View File
@@ -51,10 +51,6 @@ const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as cons
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
publicHost: string;
}
@@ -162,7 +158,7 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
}
export interface UseClientsOptions {
// Callers that only need the mutations — the bulk modals, the groups page —
// Callers that only need the mutations — the bulk modals —
// pass false. Mounting them used to start a second 5-second poll of the paged
// list whose result they never read, which on a large panel means a full
// summary aggregate every 5 seconds for nothing.
@@ -267,22 +263,9 @@ export function useClients(options: UseClientsOptions = {}) {
() => ({
enable: !!defaults.subEnable,
subURI: (defaults.subURI as string) || '',
subJsonURI: (defaults.subJsonURI as string) || '',
subJsonEnable: !!defaults.subJsonEnable,
subClashURI: (defaults.subClashURI as string) || '',
subClashEnable: !!defaults.subClashEnable,
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
}),
[
defaults.subEnable,
defaults.subURI,
defaults.subJsonURI,
defaults.subJsonEnable,
defaults.subClashURI,
defaults.subClashEnable,
defaults.subDomain,
defaults.webDomain,
],
[defaults.subEnable, defaults.subURI, defaults.subDomain, defaults.webDomain],
);
const ipLimitEnable = !!defaults.ipLimitEnable;
@@ -304,7 +287,6 @@ export function useClients(options: UseClientsOptions = {}) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
]);
}, [queryClient]);
@@ -328,22 +310,6 @@ export function useClients(options: UseClientsOptions = {}) {
},
});
const bulkAddToGroupMut = useMutation({
mutationFn: (body: { emails: string[]; group: string }) =>
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
});
const bulkRemoveFromGroupMut = useMutation({
mutationFn: (body: { emails: string[] }) =>
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
});
const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
@@ -583,20 +549,6 @@ export function useClients(options: UseClientsOptions = {}) {
},
[bulkSetEnableMut],
);
const bulkAddToGroup = useCallback(
(emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAddToGroupMut.mutateAsync({ emails, group });
},
[bulkAddToGroupMut],
);
const bulkRemoveFromGroup = useCallback(
(emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkRemoveFromGroupMut.mutateAsync({ emails });
},
[bulkRemoveFromGroupMut],
);
const attach = useCallback(
(email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
@@ -807,8 +759,6 @@ export function useClients(options: UseClientsOptions = {}) {
bulkAdjust,
bulkEnable,
bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
setExternalLinks,
bulkAttach,
+1 -1
View File
@@ -3,7 +3,7 @@ import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
type Calendar = 'gregorian' | 'jalalian';
type Calendar = 'gregorian';
let cachedValue: Calendar = 'gregorian';
let fetched = false;
+1 -6
View File
@@ -6,14 +6,9 @@ const TITLE_KEYS: Record<string, string> = {
'/': 'menu.dashboard',
'/inbounds': 'menu.inbounds',
'/clients': 'menu.clients',
'/groups': 'menu.groups',
'/nodes': 'menu.nodes',
'/hosts': 'menu.hosts',
'/settings': 'menu.settings',
'/xray': 'menu.xray',
'/outbound': 'menu.outbounds',
'/routing': 'menu.routing',
'/api-docs': 'menu.apiDocs',
};
export function usePageTitle() {
@@ -22,7 +17,7 @@ export function usePageTitle() {
useEffect(() => {
const key = TITLE_KEYS[pathname];
const title = key ? t(key) : '3X-UI';
const title = key ? t(key) : 'L-UI';
const host = window.location.hostname;
document.title = host ? `${host} - ${title}` : title;
}, [pathname, t]);
-542
View File
@@ -1,542 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import {
OutboundTrafficListSchema,
OutboundTestResultListSchema,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTestResult,
type OutboundTrafficRow,
} from '@/schemas/xray';
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// One HTTP-mode batch request tests this many outbounds through a single
// shared temp xray instance; chunking keeps responses bounded (~30s worst
// case — each probe is a cold plus a warm request) and lands Test All
// results progressively.
const HTTP_BATCH_CHUNK = 16;
function normalizeOutboundTestUrl(url: string) {
return url || DEFAULT_TEST_URL;
}
export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as
| { protocol?: string; streamSettings?: { network?: string } }
| null
| undefined;
const p = o?.protocol;
const n = o?.streamSettings?.network;
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
}
export type OutboundTestMode = 'tcp' | 'http' | 'real';
export type { OutboundTrafficRow, OutboundTestResult };
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export interface OutboundTestState {
testing?: boolean;
result?: OutboundTestResult | null;
mode?: string;
}
export type SetTemplate = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void;
export interface UseXraySettingResult {
fetched: boolean;
spinning: boolean;
saveDisabled: boolean;
fetchError: string;
xraySetting: string;
setXraySetting: (next: string) => void;
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundTestUrl: string;
setOutboundTestUrl: (v: string) => void;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutbounds: unknown[];
subscriptionOutboundTags: string[];
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => Promise<void>;
testOutbound: (
index: number,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testSubscriptionOutbound: (
tag: string,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testAllOutbounds: (mode?: string) => Promise<void>;
saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>;
}
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
if (typeof msg.obj !== 'string')
throw new Error('Malformed xray config response: expected string');
let parsed: unknown;
try {
parsed = JSON.parse(msg.obj);
} catch (e) {
const err = e as Error;
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
}
const result = XrayConfigPayloadSchema.safeParse(parsed);
if (!result.success) {
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
return parsed as XrayConfigPayload;
}
return result.data;
}
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, {
silent: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useXraySetting(): UseXraySettingResult {
const queryClient = useQueryClient();
const configQuery = useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
});
const trafficQuery = useQuery({
queryKey: keys.xray.outboundsTraffic(),
queryFn: fetchOutboundsTraffic,
staleTime: Infinity,
});
const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [savedXraySetting, setSavedXraySetting] = useState('');
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
const config = configQuery.data;
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
const subscriptionOutbounds = useMemo<unknown[]>(
() => config?.subscriptionOutbounds || [],
[config],
);
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
{},
);
// Subscription outbounds aren't in templateSettings.outbounds, so their test
// results are keyed by tag rather than by index.
const [subscriptionTestStates, setSubscriptionTestStates] = useState<
Record<string, OutboundTestState>
>({});
const [testingAll, setTestingAll] = useState(false);
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const savedXraySettingRef = useRef(savedXraySetting);
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
useEffect(() => {
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
savedXraySettingRef.current = savedXraySetting;
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
});
// Adopt a fetched config during render, so the editor never paints one frame
// of the previous config after a refetch. Local edits win over the refetch.
if (config && config !== syncedConfig) {
setSyncedConfig(config);
const isDirty =
savedXraySetting !== xraySetting ||
savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
if (!isDirty) {
const pretty = JSON.stringify(config.xraySetting, null, 2);
const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
setXraySettingState(pretty);
setTemplateSettingsState(config.xraySetting);
setSavedXraySetting(pretty);
setOutboundTestUrlState(nextUrl);
setSavedOutboundTestUrl(nextUrl);
}
}
const fetched = configQuery.data !== undefined || configQuery.isError;
const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
const setXraySetting = useCallback((next: string) => {
setXraySettingState(next);
if (syncingRef.current) return;
try {
const parsed = JSON.parse(next);
syncingRef.current = true;
setTemplateSettingsState(parsed);
syncingRef.current = false;
} catch {
/* ignore — wait for user to finish */
}
}, []);
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
setTemplateSettingsState((prev) => {
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
if (next == null) return next;
if (!syncingRef.current) {
try {
syncingRef.current = true;
setXraySettingState(JSON.stringify(next, null, 2));
} finally {
syncingRef.current = false;
}
}
return next;
});
}, []);
const setOutboundTestUrl = useCallback((v: string) => {
setOutboundTestUrlState(v);
}, []);
const fetchAll = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, [queryClient]);
const fetchOutboundsTrafficCb = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
}, [queryClient]);
const saveMut = useMutation({
mutationFn: async () => {
const sentXraySetting = xraySettingRef.current;
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
});
return { msg, sentXraySetting, sentTestUrl };
},
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
if (!msg?.success) return;
setSavedXraySetting(sentXraySetting);
setSavedOutboundTestUrl(sentTestUrl);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
const resetTrafficMut = useMutation({
mutationFn: (tag: string) => HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
},
});
const resetDefaultMut = useMutation({
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
},
onSuccess: (msg) => {
if (msg?.success && msg.obj) {
const cloned = JSON.parse(JSON.stringify(msg.obj));
setTemplateSettings(cloned);
}
},
});
const saveAll = useCallback(async () => {
await saveMut.mutateAsync();
}, [saveMut]);
const resetOutboundsTraffic = useCallback(
async (tag: string) => {
await resetTrafficMut.mutateAsync(tag);
},
[resetTrafficMut],
);
const resetToDefault = useCallback(async () => {
await resetDefaultMut.mutateAsync();
}, [resetDefaultMut]);
const spinning = saveMut.isPending || resetDefaultMut.isPending;
// Shared POST + parse for a batch of outbound tests. The backend probes the
// whole batch through one shared temp xray instance and returns results in
// request order; this aligns them by index and shapes failures so every
// input gets an OutboundTestResult.
const postOutboundTestBatch = useCallback(
async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
const failAll = (error: string): OutboundTestResult[] =>
outbounds.map(() => ({ success: false, error, mode: effMode }));
try {
const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
outbounds: JSON.stringify(outbounds),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
});
const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
const list = msg.obj;
return outbounds.map(
(_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode },
);
} catch (e) {
return failAll(String(e));
}
},
[],
);
const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound) return null;
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: true, result: null, mode: effMode },
}));
const [result] = await postOutboundTestBatch([outbound], effMode);
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
return result.success ? result : null;
},
[postOutboundTestBatch],
);
// Test a subscription outbound (not present in templateSettings.outbounds);
// results are keyed by tag in subscriptionTestStates.
const testSubscriptionOutbound = useCallback(
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound || !tag) return null;
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
setSubscriptionTestStates((prev) => ({
...prev,
[tag]: { testing: true, result: null, mode: effMode },
}));
const [result] = await postOutboundTestBatch([outbound], effMode);
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
return result.success ? result : null;
},
[postOutboundTestBatch],
);
const testAllOutbounds = useCallback(
async (mode = 'tcp') => {
// Template outbounds key their results by index (outboundTestStates);
// subscription outbounds aren't in the template, so they key by tag
// (subscriptionTestStates). Both go through the same probe endpoint.
const templateList = templateSettingsRef.current?.outbounds || [];
const subList = (subscriptionOutboundsRef.current || []) as Array<{
tag?: string;
protocol?: string;
}>;
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
setTestingAll(true);
try {
type TcpEntry =
| { kind: 'tpl'; index: number; outbound: unknown }
| { kind: 'sub'; tag: string; outbound: unknown };
const tcpQueue: TcpEntry[] = [];
// HTTP batches stay homogeneous (all template or all subscription) so a
// tag shared between a template and a subscription outbound can't collide
// inside one batch, and each batch's results route to one state map.
const probeMode = mode === 'real' ? 'real' : 'http';
const httpTplQueue: { index: number; outbound: unknown }[] = [];
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
const enqueue = (
ob: { tag?: string; protocol?: string },
kind: 'tpl' | 'sub',
index: number,
tag: string,
) => {
const proto = ob?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
// freedom ("direct") and dns aren't proxies — skip them in every mode.
if (proto === 'freedom' || proto === 'dns') return;
if (kind === 'sub' && !tag) return;
const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
if (kind === 'tpl') {
if (toHttp) httpTplQueue.push({ index, outbound: ob });
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
} else if (toHttp) {
httpSubQueue.push({ tag, outbound: ob });
} else {
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
}
};
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
// TCP probes are dial-only and cheap server-side; per-item requests
// keep results landing one by one, each routed to its own state map.
const runTcpLane = async () => {
const queue = [...tcpQueue];
const worker = async () => {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
if (item.kind === 'sub')
await testSubscriptionOutbound(item.tag, item.outbound, mode);
else await testOutbound(item.index, item.outbound, mode);
}
};
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
};
// HTTP probes go out as chunked batches — one temp xray spawn per
// chunk instead of one per outbound, with results landing per chunk.
const runTplHttpLane = async () => {
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
setOutboundTestStates((prev) => {
const next = { ...prev };
for (const item of chunk)
next[item.index] = { testing: true, result: null, mode: probeMode };
return next;
});
const results = await postOutboundTestBatch(
chunk.map((c) => c.outbound),
probeMode,
);
setOutboundTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.index] = { testing: false, result: results[i] };
});
return next;
});
}
};
const runSubHttpLane = async () => {
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
setSubscriptionTestStates((prev) => {
const next = { ...prev };
for (const item of chunk)
next[item.tag] = { testing: true, result: null, mode: probeMode };
return next;
});
const results = await postOutboundTestBatch(
chunk.map((c) => c.outbound),
probeMode,
);
setSubscriptionTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.tag] = { testing: false, result: results[i] };
});
return next;
});
}
};
// HTTP batches must not overlap: the backend serialises them with a
// non-blocking lock and rejects a second concurrent batch ("Another
// outbound test is already running"). Run the template and subscription
// HTTP lanes one after the other; TCP probes don't take that lock, so
// they still run alongside.
const runHttpLane = async () => {
await runTplHttpLane();
await runSubHttpLane();
};
await Promise.all([runTcpLane(), runHttpLane()]);
} finally {
setTestingAll(false);
}
},
[testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch],
);
const saveDisabled =
savedXraySetting === xraySetting &&
savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
return useMemo(
() => ({
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
}),
[
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
],
);
}
+21 -52
View File
@@ -6,26 +6,23 @@ import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CloseOutlined,
CloudServerOutlined,
ClusterOutlined,
CodeOutlined,
DashboardOutlined,
DatabaseOutlined,
ExportOutlined,
GithubOutlined,
GlobalOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MailOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
MoonOutlined,
PushpinFilled,
PushpinOutlined,
RadarChartOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
@@ -44,7 +41,7 @@ import './AppSidebar.css';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const REPO_URL = 'https://git.881218.xyz/rogue/3x-ui-agent';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const SIDER_WIDTH = 220;
@@ -176,9 +173,7 @@ export default function AppSidebar() {
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname, hash } = useLocation();
const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const showSubBalancers = !!allSetting.subJsonEnable;
useAllSettings();
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [pinned, setPinned] = useState(readSidebarPinned);
@@ -217,14 +212,9 @@ export default function AppSidebar() {
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
],
[t],
@@ -250,51 +240,33 @@ export default function AppSidebar() {
icon: <MessageOutlined />,
label: t('pages.settings.TGBotSettings'),
},
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
{
key: '/settings#subscription',
icon: <CloudServerOutlined />,
label: t('pages.settings.subSettings'),
},
{
key: '/settings#public-page',
icon: <GlobalOutlined />,
label: t('pages.settings.probeThemes.title'),
},
{
key: '/settings#probe',
icon: <RadarChartOutlined />,
label: t('pages.settings.probe.title'),
},
];
if (showSubFormats) {
children.push({
key: '/settings#subscription-formats',
icon: <CodeOutlined />,
label: t('menu.subFormats'),
});
}
if (showSubBalancers) {
children.push({
key: '/settings#subscription-balancers',
icon: <ApartmentOutlined />,
label: t('pages.settings.subBalancers.menu'),
});
}
return children;
}, [t, showSubFormats, showSubBalancers]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
],
[t],
);
}, [t]);
const settingsActive = pathname === '/settings';
const xrayActive = pathname === '/xray';
const selectedKey = settingsActive
? `/settings${hash || '#general'}`
: xrayActive
? `/xray${hash || '#basic'}`
: pathname === ''
? '/'
: pathname;
: pathname === ''
? '/'
: pathname;
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
const openSubmenu = settingsActive ? '/settings' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
if (openSubmenu && !openKeys.includes(openSubmenu)) {
setOpenKeys([...openKeys, openSubmenu]);
@@ -307,12 +279,9 @@ export default function AppSidebar() {
if (tab.key === '/settings') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
}
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
}),
[settingsChildren, xrayChildren],
[settingsChildren],
);
const openLink = useCallback(
@@ -366,7 +335,7 @@ export default function AppSidebar() {
>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
<span className="brand-text">{railCollapsed ? 'L' : 'L-UI'}</span>
</div>
{!railCollapsed && (
<div className="brand-actions">
@@ -430,7 +399,7 @@ export default function AppSidebar() {
>
<div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">3X-UI</span>
<span className="drawer-brand">L-UI</span>
</div>
<div className="drawer-header-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
+3
View File
@@ -22,6 +22,9 @@ function parseVersionParts(version: string): [number, number, number] | null {
export function formatPanelVersion(version: string | undefined | null): string {
const v = (version || '').trim();
if (!v) return '';
// This fork's display versions (master "V1.0.0", agent "A1.0.0") are
// shown exactly as written — never re-prefixed or re-cased.
if (/^[VA]\d+\.\d+\.\d+$/.test(v)) return v;
const normalized = v.replace(/^v/i, '');
return /^\d/.test(normalized) ? `v${normalized}` : v;
}
@@ -1,6 +1,16 @@
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import type { BalancerObject } from '@/schemas/routing';
// The settings object these helpers read/mutate: only the routing
// balancers and the two observatory sections are touched. (This was
// XraySettingsValue from the retired xray settings page's hook; the
// helpers outlived the page because the subscription balancers tab
// still consumes the defaults.)
export interface BalancerSettingsLike {
routing?: { balancers?: BalancerObject[] } | null;
observatory?: unknown;
burstObservatory?: unknown;
}
export const DEFAULT_OBSERVATORY = Object.freeze({
subjectSelector: [] as string[],
probeURL: 'https://www.google.com/generate_204',
@@ -34,7 +44,7 @@ export function balancerRequiresBurstObservatory(b: BalancerObject): boolean {
);
}
export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean {
export function settingsRequireBurstObservatory(t: BalancerSettingsLike | null): boolean {
const balancers = (t?.routing?.balancers || []) as BalancerObject[];
return balancers.some(balancerRequiresBurstObservatory);
}
@@ -59,7 +69,7 @@ export function settingsRequireBurstObservatory(t: XraySettingsValue | null): bo
// the last leastLoad) removes the burst observer again. A no-fallback
// Random/RoundRobin balancer never expands the observer either, because those
// strategies do not consume observer data.
export function syncObservatories(t: XraySettingsValue) {
export function syncObservatories(t: BalancerSettingsLike) {
const balancers = (t.routing?.balancers || []) as BalancerObject[];
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
@@ -62,7 +62,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="IP/CIDR/geoip:*/ext:*"
placeholder="IP/CIDR"
style={{ width: '100%' }}
/>
</Form.Item>
@@ -73,7 +73,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="domain:*/ext:*"
placeholder="domain:*"
style={{ width: '100%' }}
/>
</Form.Item>
@@ -0,0 +1,2 @@
export { default as SockoptForm } from './sockopt';
export { default as MuxForm } from './mux';
-81
View File
@@ -1,81 +0,0 @@
import type { GeoKind } from '@/generated/types';
const DEFAULT_SITE_FILE = 'geosite.dat';
const DEFAULT_IP_FILE = 'geoip.dat';
const LONG_FORMS: Array<[RegExp, string]> = [
[/^ext(?:-domain|-site)?:geosite\.dat:/, 'geosite:'],
[/^ext(?:-ip)?:geoip\.dat:/, 'geoip:'],
];
export function parseTokens(value: string): string[] {
return value
.split(',')
.map((token) => token.trim())
.filter((token) => token !== '');
}
export function formatTokens(tokens: string[]): string {
return tokens.join(', ');
}
export function tokenFor(file: string, code: string, kind: GeoKind): string {
if (kind === 'ip' && file === DEFAULT_IP_FILE) return `geoip:${code}`;
if (kind === 'site' && file === DEFAULT_SITE_FILE) return `geosite:${code}`;
return `ext:${file}:${code}`;
}
/**
* Xray treats category codes case-insensitively and accepts both the
* `geosite:cn` shorthand and its `ext:geosite.dat:cn` long form, so tokens are
* compared through this normal form. Only comparison uses it — whatever the
* user typed is what stays in the rule.
*/
export function canonicalToken(token: string): string {
const lowered = token.trim().toLowerCase();
for (const [pattern, shorthand] of LONG_FORMS) {
if (pattern.test(lowered)) return lowered.replace(pattern, shorthand);
}
return lowered;
}
export function selectionFromValue(value: string, known: ReadonlySet<string>): string[] {
const canonicalKnown = new Set([...known].map(canonicalToken));
const selection: string[] = [];
const seen = new Set<string>();
for (const token of parseTokens(value)) {
const canonical = canonicalToken(token);
if (!canonicalKnown.has(canonical) || seen.has(canonical)) continue;
seen.add(canonical);
selection.push(token);
}
return selection;
}
export function mergeSelection(
value: string,
selected: string[],
known: ReadonlySet<string>,
): string {
const canonicalKnown = new Set([...known].map(canonicalToken));
const kept = new Set(
selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
);
const merged: string[] = [];
const seen = new Set<string>();
const append = (token: string) => {
const canonical = canonicalToken(token);
if (canonical === '' || seen.has(canonical)) return;
seen.add(canonical);
merged.push(token);
};
for (const token of parseTokens(value)) {
const canonical = canonicalToken(token);
if (canonicalKnown.has(canonical) && !kept.has(canonical)) continue;
append(token);
}
for (const token of selected) {
append(token.trim());
}
return formatTokens(merged);
}
+25 -6
View File
@@ -1,7 +1,9 @@
import type {
InboundFormValues,
ShareAddrStrategy,
TrafficReset,
import {
ForwardingSpecSchema,
type ForwardingSpec,
type InboundFormValues,
type ShareAddrStrategy,
type TrafficReset,
} from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound';
import {
@@ -49,6 +51,7 @@ export interface RawInboundRow {
trafficResetDay?: number;
lastTrafficResetTime?: number;
nodeId?: number | null;
forwarding?: unknown;
shareAddrStrategy?: string;
shareAddr?: string;
subSortIndex?: number;
@@ -77,7 +80,8 @@ export interface WireInboundPayload {
sniffing: string;
tag: string;
clientStats?: unknown;
nodeId?: number;
nodeId?: number | null;
forwarding: string;
shareAddrStrategy: ShareAddrStrategy;
shareAddr: string;
subSortIndex: number;
@@ -111,6 +115,11 @@ function coerceTrafficReset(v: unknown): TrafficReset {
: 'never';
}
function coerceForwarding(v: unknown): ForwardingSpec {
const parsed = ForwardingSpecSchema.safeParse(coerceJsonObject(v));
return parsed.success ? parsed.data : ForwardingSpecSchema.parse({});
}
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
? (v as ShareAddrStrategy)
@@ -213,6 +222,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null,
forwarding: coerceForwarding(row.forwarding),
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
shareAddr: row.shareAddr ?? '',
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
@@ -383,7 +393,16 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
shareAddr: values.shareAddr,
subSortIndex: values.subSortIndex,
disableFlow: values.disableFlow,
// Forwarding rides as a JSON string like settings; direct mode stores
// an empty string so the row carries no forwarding residue.
forwarding:
values.forwarding && values.forwarding.mode === 'forward'
? JSON.stringify(values.forwarding)
: '',
};
if (values.nodeId != null) payload.nodeId = values.nodeId;
// nodeId is always emitted (null = local panel): the update endpoint
// distinguishes an explicit null — "move the inbound back to local" —
// from an omitted key, which keeps the stored assignment untouched.
payload.nodeId = values.nodeId ?? null;
return payload;
}
+27 -20
View File
@@ -14,7 +14,6 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
@@ -353,7 +352,6 @@ export interface GenVlessLinkInput {
forceTls?: ForceTls;
remark?: string;
clientId: string;
clientKey?: string;
flow?: VlessClient['flow'];
externalProxy?: ExternalProxyEntry | null;
}
@@ -374,7 +372,7 @@ export function applyVlessRoute(id: string, route: string | undefined): string {
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
// query carries network type, encryption, network-specific knobs, and
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
// pbk/sid/spx). Returns '' if the inbound isn't vless.
// pbk/sid). Returns '' if the inbound isn't vless.
export function genVlessLink(input: GenVlessLinkInput): string {
const {
inbound,
@@ -383,7 +381,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
forceTls = 'same',
remark = '',
clientId,
clientKey = '',
flow = '',
externalProxy = null,
} = input;
@@ -462,8 +459,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (sni && sni.length > 0) params.set('sni', sni);
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
if (spx.length > 0) params.set('spx', spx);
if (reality.settings.mldsa65Verify.length > 0)
params.set('pqv', reality.settings.mldsa65Verify);
}
@@ -560,7 +555,6 @@ function writeTlsParams(
function writeRealityParams(
stream: NonNullable<Inbound['streamSettings']>,
params: URLSearchParams,
clientKey: string,
): void {
if (stream.security !== 'reality') return;
const reality = stream.realitySettings;
@@ -573,8 +567,6 @@ function writeRealityParams(
if (sni && sni.length > 0) params.set('sni', sni);
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
if (spx.length > 0) params.set('spx', spx);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
}
@@ -585,7 +577,6 @@ export interface GenTrojanLinkInput {
forceTls?: ForceTls;
remark?: string;
clientPassword: string;
clientKey?: string;
externalProxy?: ExternalProxyEntry | null;
}
@@ -600,7 +591,6 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
forceTls = 'same',
remark = '',
clientPassword,
clientKey = '',
externalProxy = null,
} = input;
@@ -621,7 +611,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
params.set('security', 'reality');
writeRealityParams(stream, params, clientKey);
writeRealityParams(stream, params);
} else {
params.set('security', 'none');
}
@@ -1223,6 +1213,31 @@ export interface ShareHostFields {
shareAddrStrategy?: string;
}
// The subset of a node record the share-host pick reads.
export interface NodeHostFields {
address?: string;
ipv4?: string;
ipv6?: string;
nodeType?: string;
}
// shareHostForNode picks the hostOverride for share/QR/export links from a
// node record. Agent-managed nodes carry the literal placeholder "agent" in
// address — the create form's sentinel, not a connectable host — while the
// backend-resolved externally usable address rides in ipv4/ipv6
// (annotateAgentTelemetry), which is what every other surface renders.
// Resolved addresses therefore win; the raw address is only a fallback and
// only when it is not the placeholder, so the placeholder can never leak
// into a link. With no resolved address the pick is empty and
// resolveShareHost's fallback chain (listen → panel host) continues.
export function shareHostForNode(node: NodeHostFields | undefined | null): string {
if (!node) return '';
const resolved = (node.ipv4 ?? '').trim() || (node.ipv6 ?? '').trim();
if (resolved) return resolved;
const raw = (node.address ?? '').trim();
return raw.toLowerCase() === 'agent' ? '' : raw;
}
// resolveShareHost picks the host that goes into share/QR links, the browser-side
// analog of the backend resolveInboundAddress. hostOverride is the hosting node's
// address (empty for this panel's own inbounds); fallbackHostname is the
@@ -1296,12 +1311,6 @@ type ClientShape = {
subId?: string;
};
// Mirror of the Go subKey: the stable per-client identity spx derivation
// keys on — subscription id first, unique email as the fallback.
function clientSubKey(client: ClientShape): string {
return client.subId || client.email || '';
}
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
switch (inbound.protocol) {
case 'vmess':
@@ -1367,7 +1376,6 @@ export function genLink(input: GenLinkInput): string {
forceTls,
remark,
clientId: client.id ?? '',
clientKey: clientSubKey(client),
flow: client.flow,
externalProxy,
});
@@ -1391,7 +1399,6 @@ export function genLink(input: GenLinkInput): string {
forceTls,
remark,
clientPassword: client.password ?? '',
clientKey: clientSubKey(client),
externalProxy,
});
case 'hysteria':
-10
View File
@@ -1,10 +0,0 @@
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
// links and subscription links agree; returns '' when there is no seed and
// no client key (the caller then omits spx, as the legacy builder did).
export function deriveSpiderX(seed: string, clientKey: string): string {
if (!seed && !clientKey) return '';
return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
}
+3
View File
@@ -39,6 +39,7 @@ export type DBInboundInit = Partial<{
streamSettings: RawJsonField;
tag: string;
sniffing: RawJsonField;
forwarding: string;
clientStats: ClientStats[];
nodeId: number | null;
shareAddrStrategy: string;
@@ -88,6 +89,7 @@ export class DBInbound {
streamSettings: RawJsonField;
tag: string;
sniffing: RawJsonField;
forwarding: string;
clientStats: ClientStats[];
nodeId: number | null;
shareAddrStrategy: string;
@@ -119,6 +121,7 @@ export class DBInbound {
this.streamSettings = '';
this.tag = '';
this.sniffing = '';
this.forwarding = '';
this.clientStats = [];
this.nodeId = null;
this.shareAddrStrategy = 'node';
+2 -73
View File
@@ -9,14 +9,12 @@ export class AllSetting {
webBasePath = '/';
sessionMaxAge = 360;
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
ipLimitAllowlist = '';
panelOutbound = '';
pageSize = 25;
expireDiff = 0;
trafficDiff = 0;
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
subShowIdentityOnAllLinks = false;
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
datepicker = 'gregorian' as const;
tgBotEnable = false;
tgBotToken = '';
tgBotAPIServer = '';
@@ -26,97 +24,32 @@ export class AllSetting {
tgCpu = 80;
tgMemory = 80;
tgLang = 'en-US';
twoFactorEnable = false;
twoFactorToken = '';
xrayTemplateConfig = '';
trafficStatsSwitches = '';
subEnable = true;
subJsonEnable = false;
subJsonAutoDetect = false;
subJsonAlwaysArray = false;
subJsonUserAgentRegex = '';
subClashAutoDetect = false;
subClashUserAgentRegex = '';
subTitle = '';
subSupportUrl = '';
subProfileUrl = '';
subAnnounce = '';
subEnableRouting = false;
subRoutingRules = '';
subIncyEnableRouting = false;
subIncyRoutingRules = '';
subListen = '';
subPort = 2096;
subPath = '/sub/';
subJsonPath = '/json/';
subClashEnable = false;
subClashPath = '/clash/';
subDomain = '';
externalTrafficInformEnable = false;
externalTrafficInformURI = '';
restartXrayOnClientDisable = true;
subCertFile = '';
subKeyFile = '';
subUpdates = 12;
subEncrypt = true;
subURI = '';
subJsonURI = '';
subClashURI = '';
subClashEnableRouting = false;
subClashRules = '';
subJsonMux = '';
subJsonRules = '';
subJsonFinalMask = '';
subJsonObservatory = '';
subThemeDir = '';
subHideSettings = false;
timeLocation = 'Local';
ldapEnable = false;
ldapHost = '';
ldapPort = 389;
ldapUseTLS = false;
ldapInsecureSkipVerify = false;
ldapBindDN = '';
ldapPassword = '';
ldapBaseDN = '';
ldapUserFilter = '(objectClass=person)';
ldapUserAttr = 'mail';
ldapVlessField = 'vless_enabled';
ldapSyncCron = '@every 1m';
ldapFlagField = '';
ldapTruthyValues = 'true,1,yes,on';
ldapInvertFlag = false;
ldapInboundTags = '';
ldapAutoCreate = false;
ldapAutoDelete = false;
ldapDefaultTotalGB = 0;
ldapDefaultExpiryDays = 0;
ldapDefaultLimitIP = 0;
tgEnabledEvents = 'login.attempt,cpu.high';
smtpEnable = false;
smtpHost = '';
smtpPort = 587;
smtpUsername = '';
smtpPassword = '';
smtpFrom = '';
smtpFromName = '';
smtpTo = '';
smtpEncryptionType = 'starttls';
smtpEnabledEvents = 'login.attempt,cpu.high';
smtpCpu = 80;
smtpMemory = 80;
outboundDownThreshold = 3;
hasTgBotToken = false;
hasTwoFactorToken = false;
hasLdapPassword = false;
hasApiToken = false;
hasWarpSecret = false;
hasNordSecret = false;
hasSmtpPassword = false;
clearTgBotToken = false;
clearLdapPassword = false;
clearSmtpPassword = false;
constructor(data?: unknown) {
if (data != null) {
@@ -124,10 +57,6 @@ export class AllSetting {
}
const cpu = Math.round(Number(this.tgCpu));
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
const threshold = Math.round(Number(this.outboundDownThreshold));
this.outboundDownThreshold = Number.isFinite(threshold)
? Math.min(100, Math.max(1, threshold))
: 3;
}
equals(other: AllSetting): boolean {
-421
View File
@@ -1,421 +0,0 @@
.api-docs-page.is-dark {
--sw-bg: #1f2026;
--sw-bg-soft: #25272e;
--sw-bg-input: #15161a;
--sw-bg-code: #0d0e12;
--sw-border: rgba(255, 255, 255, 0.08);
--sw-border-strong: rgba(255, 255, 255, 0.15);
--sw-text: rgba(255, 255, 255, 0.88);
--sw-text-muted: rgba(255, 255, 255, 0.6);
--sw-text-dim: rgba(255, 255, 255, 0.45);
--sw-accent: #58a6ff;
color-scheme: dark;
}
.api-docs-page.is-dark.is-ultra {
--sw-bg: #0a0a0d;
--sw-bg-soft: #131316;
--sw-bg-input: #050507;
--sw-bg-code: #000;
--sw-border: rgba(255, 255, 255, 0.06);
--sw-border-strong: rgba(255, 255, 255, 0.12);
}
.api-docs-page .content-shell {
background: var(--bg-page);
}
.api-docs-page .content-area {
padding: 16px;
max-width: 100%;
}
@media (max-width: 768px) {
.api-docs-page .content-area {
padding: 8px;
padding-top: 56px;
}
}
.api-docs-page .docs-wrapper {
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--ant-color-border-secondary);
overflow: hidden;
}
.api-docs-page .swagger-ui {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* ──────────────────────────────────────────────────────────────────
Dark mode — Swagger UI does not ship a dark theme, so every visual
surface needs an explicit override. Method-color chips (GET / POST /
…) are left untouched because they carry meaning at a glance.
────────────────────────────────────────────────────────────────── */
.api-docs-page.is-dark .swagger-ui,
.api-docs-page.is-dark .swagger-ui .info .title,
.api-docs-page.is-dark .swagger-ui .info .title small pre,
.api-docs-page.is-dark .swagger-ui .info p,
.api-docs-page.is-dark .swagger-ui .info li,
.api-docs-page.is-dark .swagger-ui .info table,
.api-docs-page.is-dark .swagger-ui .opblock-tag,
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path__deprecated,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-description,
.api-docs-page.is-dark .swagger-ui .opblock-description-wrapper p,
.api-docs-page.is-dark .swagger-ui .opblock-external-docs-wrapper p,
.api-docs-page.is-dark .swagger-ui .opblock-title_normal p,
.api-docs-page.is-dark .swagger-ui table thead tr td,
.api-docs-page.is-dark .swagger-ui table thead tr th,
.api-docs-page.is-dark .swagger-ui table tbody tr td,
.api-docs-page.is-dark .swagger-ui .parameter__name,
.api-docs-page.is-dark .swagger-ui .parameter__type,
.api-docs-page.is-dark .swagger-ui .parameter__in,
.api-docs-page.is-dark .swagger-ui .parameter__extension,
.api-docs-page.is-dark .swagger-ui .response-col_status,
.api-docs-page.is-dark .swagger-ui .response-col_description,
.api-docs-page.is-dark .swagger-ui .response-col_links,
.api-docs-page.is-dark .swagger-ui .responses-inner h4,
.api-docs-page.is-dark .swagger-ui .responses-inner h5,
.api-docs-page.is-dark .swagger-ui label,
.api-docs-page.is-dark .swagger-ui .tab li,
.api-docs-page.is-dark .swagger-ui .tab li button,
.api-docs-page.is-dark .swagger-ui .markdown,
.api-docs-page.is-dark .swagger-ui .markdown p,
.api-docs-page.is-dark .swagger-ui .markdown li,
.api-docs-page.is-dark .swagger-ui .renderedmarkdown p,
.api-docs-page.is-dark .swagger-ui .renderedmarkdown li,
.api-docs-page.is-dark .swagger-ui .model-title,
.api-docs-page.is-dark .swagger-ui .model,
.api-docs-page.is-dark .swagger-ui .model-toggle:after,
.api-docs-page.is-dark .swagger-ui section.models h4,
.api-docs-page.is-dark .swagger-ui section.models h5,
.api-docs-page.is-dark .swagger-ui .auth-container h4,
.api-docs-page.is-dark .swagger-ui .auth-container h6,
.api-docs-page.is-dark .swagger-ui .scopes h2,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header h3,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content h4,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content p,
.api-docs-page.is-dark .swagger-ui .servers-title {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
.api-docs-page.is-dark .swagger-ui .opblock-summary-description,
.api-docs-page.is-dark .swagger-ui .parameter__in,
.api-docs-page.is-dark .swagger-ui .parameter__type,
.api-docs-page.is-dark .swagger-ui .parameter__extension,
.api-docs-page.is-dark .swagger-ui .opblock-title_normal small,
.api-docs-page.is-dark .swagger-ui .servers > label,
.api-docs-page.is-dark .swagger-ui .servers > label span,
.api-docs-page.is-dark .swagger-ui .response-control-media-type__accept-message {
color: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-tag {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock {
background: var(--sw-bg-soft);
border-color: var(--sw-border);
box-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header {
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 -1px 0 var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header h4,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header label {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock-body pre.microlight,
.api-docs-page.is-dark .swagger-ui .highlight-code,
.api-docs-page.is-dark .swagger-ui .microlight,
.api-docs-page.is-dark .swagger-ui pre.example,
.api-docs-page.is-dark .swagger-ui code {
background: var(--sw-bg-code);
color: rgba(255, 255, 255, 0.92);
}
.api-docs-page.is-dark .swagger-ui .highlight-code .copy-to-clipboard {
background: rgba(255, 255, 255, 0.06);
}
.api-docs-page.is-dark .swagger-ui input[type='text'],
.api-docs-page.is-dark .swagger-ui input[type='password'],
.api-docs-page.is-dark .swagger-ui input[type='search'],
.api-docs-page.is-dark .swagger-ui input[type='email'],
.api-docs-page.is-dark .swagger-ui input[type='file'],
.api-docs-page.is-dark .swagger-ui textarea {
background: var(--sw-bg-input);
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui select {
background-color: var(--sw-bg-input);
background-image: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='%23b7bcbf' d='M13.418 7.859a.695.695 0 0 1 .978 0 .68.68 0 0 1 0 .969l-3.908 3.83a.697.697 0 0 1-.979 0l-3.908-3.83a.68.68 0 0 1 0-.969.695.695 0 0 1 .978 0L10 11z'/></svg>");
background-position: right 10px center;
background-repeat: no-repeat;
background-size: 20px;
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui select option {
background-color: var(--sw-bg-input);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.api-docs-page.is-dark .swagger-ui input::placeholder,
.api-docs-page.is-dark .swagger-ui textarea::placeholder {
color: var(--sw-text-dim);
}
.api-docs-page.is-dark .swagger-ui .scheme-container {
background: var(--sw-bg-soft);
box-shadow: inset 0 -1px 0 var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize {
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize svg {
fill: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .model-box,
.api-docs-page.is-dark .swagger-ui section.models {
background: var(--sw-bg-soft);
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models.is-open h4 {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models .model-container {
background: rgba(255, 255, 255, 0.02);
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models .model-container:hover {
background: rgba(255, 255, 255, 0.04);
}
.api-docs-page.is-dark .swagger-ui .prop-type,
.api-docs-page.is-dark .swagger-ui .prop-format {
color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui .property.primitive {
color: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-title_normal h4 {
color: var(--sw-text);
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table.parameters,
.api-docs-page.is-dark .swagger-ui table.responses-table,
.api-docs-page.is-dark .swagger-ui table thead tr {
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table tbody tr td {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table thead tr {
border-bottom-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .response-col_status {
font-weight: 600;
}
.api-docs-page.is-dark .swagger-ui .btn {
background: rgba(255, 255, 255, 0.06);
color: var(--sw-text);
border-color: var(--sw-border-strong);
box-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.api-docs-page.is-dark .swagger-ui .btn.execute {
background: var(--sw-accent);
color: #0d1117;
border-color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui .btn.execute:hover {
background: #79b9ff;
}
.api-docs-page.is-dark .swagger-ui .btn.authorize {
color: #52c41a;
border-color: rgba(82, 196, 26, 0.4);
background: rgba(82, 196, 26, 0.08);
}
.api-docs-page.is-dark .swagger-ui .btn.authorize svg {
fill: #52c41a;
}
.api-docs-page.is-dark .swagger-ui .authorization__btn svg,
.api-docs-page.is-dark .swagger-ui .expand-operation svg,
.api-docs-page.is-dark .swagger-ui .opblock-control-arrow svg {
fill: var(--sw-text);
opacity: 1;
}
.api-docs-page.is-dark .swagger-ui .authorization__btn .locked,
.api-docs-page.is-dark .swagger-ui .authorization__btn .unlocked {
opacity: 1;
}
.api-docs-page.is-dark .swagger-ui .btn.cancel {
color: #ff7875;
border-color: rgba(255, 120, 117, 0.4);
background: rgba(255, 120, 117, 0.08);
}
.api-docs-page.is-dark .swagger-ui .btn.btn-clear,
.api-docs-page.is-dark .swagger-ui .btn-clear,
.api-docs-page.is-dark .swagger-ui .try-out__btn {
color: var(--sw-text);
background: rgba(255, 255, 255, 0.06);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .btn.btn-clear:hover,
.api-docs-page.is-dark .swagger-ui .btn-clear:hover,
.api-docs-page.is-dark .swagger-ui .try-out__btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.api-docs-page.is-dark .swagger-ui .filter .operation-filter-input {
background: var(--sw-bg-input);
border-color: var(--sw-border-strong);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux {
background: var(--sw-bg);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .arrow,
.api-docs-page.is-dark .swagger-ui svg.arrow {
fill: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-summary-control:focus {
outline-color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui a,
.api-docs-page.is-dark .swagger-ui .info a,
.api-docs-page.is-dark .swagger-ui .info hgroup.main a,
.api-docs-page.is-dark .swagger-ui .info .base-url,
.api-docs-page.is-dark .swagger-ui .info__contact a,
.api-docs-page.is-dark .swagger-ui .info__license a,
.api-docs-page.is-dark .swagger-ui .info__tos a {
color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui a:hover {
color: #79b9ff;
}
.api-docs-page.is-dark .swagger-ui .info .title small {
background: rgba(255, 255, 255, 0.08);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .info .title small pre {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .response-control-media-type--accept-controller select {
border-color: rgba(82, 196, 26, 0.5);
}
.api-docs-page.is-dark .swagger-ui .loading-container .loading:before {
border-color: var(--sw-accent) transparent transparent;
}
.api-docs-page.is-dark .swagger-ui .json-schema-form-item input,
.api-docs-page.is-dark .swagger-ui .json-schema-form-item select {
background: var(--sw-bg-input);
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .topbar {
background: var(--sw-bg);
}
.api-docs-page.is-dark .swagger-ui .information-container {
background: transparent;
}
.api-docs-page.is-dark .swagger-ui .opblock-summary-method {
text-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .auth-btn-wrapper {
border-top-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .servers .computed-url,
.api-docs-page.is-dark .swagger-ui .computed-url {
background: var(--sw-bg-code);
color: var(--sw-text);
border: 1px solid var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .computed-url code,
.api-docs-page.is-dark .swagger-ui .servers .computed-url code {
background: transparent;
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .errors-wrapper {
background: rgba(255, 77, 79, 0.08);
border-color: rgba(255, 77, 79, 0.3);
}
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors h4,
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors small {
color: var(--sw-text);
}
@@ -1,46 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Layout } from 'antd';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import './ApiDocsPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const openApiUrl = `${basePath}panel/api/openapi.json`;
export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation();
const pageClass = useMemo(() => {
const classes = ['api-docs-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content className="content-area">
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI
url={openApiUrl}
docExpansion="list"
deepLinking={false}
tryItOutEnabled
persistAuthorization
/>
</div>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,89 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Form, Modal, message } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
type GroupFormValues = { group: string };
const EMPTY: GroupFormValues = { group: '' };
interface BulkAddToGroupModalProps {
open: boolean;
count: number;
groups: string[];
onOpenChange: (open: boolean) => void;
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
}
export default function BulkAddToGroupModal({
open,
count,
groups,
onOpenChange,
onSubmit,
}: BulkAddToGroupModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [submitting, setSubmitting] = useState(false);
const methods = useForm<GroupFormValues>({ defaultValues: EMPTY });
const group = useWatch({ control: methods.control, name: 'group' });
useEffect(() => {
if (open) methods.reset(EMPTY);
}, [open, methods]);
async function submit() {
const next = (methods.getValues().group ?? '').trim();
if (!next) return;
setSubmitting(true);
try {
const result = await onSubmit(next);
if (result) {
const affected = result.affected ?? 0;
messageApi.success(
t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }),
);
onOpenChange(false);
}
} finally {
setSubmitting(false);
}
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={t('pages.clients.addToGroupTitle', { count })}
okText={t('add')}
cancelText={t('cancel')}
confirmLoading={submitting}
okButtonProps={{ disabled: !(group ?? '').trim() }}
onCancel={() => onOpenChange(false)}
onOk={submit}
destroyOnHidden
>
<FormProvider {...methods}>
<Form layout="vertical">
<FormField
name="group"
label={t('pages.clients.group')}
tooltip={t('pages.clients.addToGroupTooltip')}
transform={{ output: (v) => v ?? '' }}
>
<AutoComplete
placeholder={t('pages.clients.groupName')}
options={groups.map((g) => ({ value: g }))}
allowClear
autoFocus
/>
</FormField>
</Form>
</FormProvider>
</Modal>
</>
);
}
@@ -37,6 +37,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'hysteria',
'wireguard',
'amneziawg',
'socks',
'mixed',
]);
const EMPTY: ClientBulkAddFormValues = {
@@ -61,6 +61,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'wireguard',
'mtproto',
'amneziawg',
'socks',
'mixed',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -51,10 +51,6 @@ const INBOUND_CHIP_LIMIT = 1;
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
publicHost?: string;
}
@@ -76,17 +72,11 @@ interface ApiMsg<T = unknown> {
const DEFAULT_SUB: SubSettings = {
enable: false,
subURI: '',
subJsonURI: '',
subJsonEnable: false,
subClashURI: '',
subClashEnable: false,
publicHost: '',
};
const SUBSCRIPTION_DOWNLOAD_NAMES = {
standard: 'subscription-standard.txt',
json: 'subscription-json.json',
clash: 'subscription-clash.yaml',
} as const;
export default function ClientInfoModal({
@@ -170,14 +160,6 @@ export default function ClientInfoModal({
const subId = client?.subId;
const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
const subJsonLink =
subId && subSettings?.subJsonEnable && subSettings?.subJsonURI
? subSettings.subJsonURI + subId
: '';
const subClashLink =
subId && subSettings?.subClashEnable && subSettings?.subClashURI
? subSettings.subClashURI + subId
: '';
const showSubscription = !!(subSettings?.enable && client?.subId);
const wgInbound = useMemo(
@@ -611,120 +593,6 @@ export default function ClientInfoModal({
</Popover>
</div>
</div>
{subJsonLink && (
<div className="link-row">
<Tag color="purple" className="link-row-tag">
JSON
</Tag>
<a
href={subJsonLink}
target="_blank"
rel="noopener noreferrer"
className="link-row-title link-row-title-anchor"
title={subJsonLink}
>
{client.subId}
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(subJsonLink)}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
loading={downloadingFormat === 'json'}
disabled={downloadingFormat !== null}
onClick={() => void downloadSubscription(subJsonLink, 'json')}
/>
</Tooltip>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<QrPanel
value={subJsonLink}
remark={`${client.email} — JSON`}
size={220}
/>
}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
</div>
</div>
)}
{subClashLink && (
<div className="link-row">
<Tooltip title="Clash / Mihomo">
<Tag color="gold" className="link-row-tag">
CLASH
</Tag>
</Tooltip>
<a
href={subClashLink}
target="_blank"
rel="noopener noreferrer"
className="link-row-title link-row-title-anchor"
title={subClashLink}
>
{client.subId}
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(subClashLink)}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
loading={downloadingFormat === 'clash'}
disabled={downloadingFormat !== null}
onClick={() => void downloadSubscription(subClashLink, 'clash')}
/>
</Tooltip>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<QrPanel
value={subClashLink}
remark={`${client.email} — Clash / Mihomo`}
size={220}
/>
}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
</div>
</div>
)}
</>
)}
+2 -18
View File
@@ -20,8 +20,6 @@ import {
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
publicHost?: string;
}
@@ -42,8 +40,6 @@ interface ApiMsg<T = unknown> {
const DEFAULT_SUB: SubSettings = {
enable: false,
subURI: '',
subJsonURI: '',
subJsonEnable: false,
publicHost: '',
};
@@ -62,10 +58,6 @@ export default function ClientQrModal({
const subId = client?.subId;
const subEnabled = !!subSettings?.enable;
const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
const subJsonLink =
subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
? subSettings.subJsonURI + subId
: '';
const wgInbound = useMemo(
() => findWireguardInbound(client, inboundsById),
@@ -97,8 +89,7 @@ export default function ClientQrModal({
);
}, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
const hasAnything =
!!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
const hasAnything = !!subLink || !!wgConfigText || !!awgConfigText || links.length > 0;
// The reset runs during render so the effect only carries the request.
const openSubId = open ? (client?.subId ?? '') : '';
@@ -142,13 +133,6 @@ export default function ClientQrModal({
),
});
}
if (subJsonLink) {
out.push({
key: 'subJson',
label: `${t('subscription.title')} (JSON)`,
children: <QrPanel value={subJsonLink} remark={`${client?.email || ''} — JSON`} />,
});
}
links.forEach((link, idx) => {
const parts = parseLinkParts(link);
const meta = parts ? linkMetaText(parts) : '';
@@ -207,7 +191,7 @@ export default function ClientQrModal({
});
}
return out;
}, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
}, [subLink, wgConfigText, awgConfigText, links, client?.email, t]);
// Expanding the first panel is a render-time adjustment, not a side effect.
const firstKey = open && items.length > 0 ? items[0].key : null;
@@ -43,7 +43,6 @@ import {
SearchOutlined,
SortAscendingOutlined,
StopOutlined,
TagsOutlined,
TeamOutlined,
UploadOutlined,
UsergroupAddOutlined,
@@ -83,7 +82,6 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
const FilterDrawer = lazy(() => import('./FilterDrawer'));
const SubLinksModal = lazy(() => import('./SubLinksModal'));
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
@@ -97,45 +95,6 @@ const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
const DEFAULT_TABLE_PAGE_SIZE = 25;
function UngroupIcon() {
return (
<span
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1em',
height: '1em',
}}
>
<TagsOutlined />
<span
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<span
style={{
display: 'block',
width: '125%',
height: '1.5px',
background: 'currentColor',
transform: 'rotate(-45deg)',
borderRadius: '1px',
}}
/>
</span>
</span>
);
}
// The server sends exact counters but caps the email arrays behind them, so a
// panel with thousands of depleted clients neither ships nor renders them all.
// The trailing chip reports what the popover left out.
@@ -314,8 +273,6 @@ export default function ClientsPage() {
bulkAdjust,
bulkEnable,
bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
setExternalLinks,
bulkAttach,
@@ -363,7 +320,6 @@ export default function ClientsPage() {
const [bulkAddOpen, setBulkAddOpen] = useState(false);
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
const [subLinksOpen, setSubLinksOpen] = useState(false);
const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
@@ -846,27 +802,6 @@ export default function ClientsPage() {
});
}
function onBulkUngroup() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
content: t('pages.clients.ungroupConfirmContent'),
okText: t('confirm'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkRemoveFromGroup(emails);
if (msg?.success) {
setSelectedRowKeys([]);
const affected =
(msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
}
},
});
}
function onBulkSetEnable(enable: boolean) {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
@@ -1382,19 +1317,6 @@ export default function ClientsPage() {
danger: true,
onClick: () => setBulkDetachOpen(true),
},
{
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true),
},
{
key: 'ungroup',
icon: <UngroupIcon />,
label: t('pages.clients.ungroup'),
danger: true,
onClick: onBulkUngroup,
},
{ type: 'divider' as const },
{
key: 'enable',
@@ -1912,22 +1834,6 @@ export default function ClientsPage() {
onOpenChange={setSubLinksOpen}
/>
</LazyMount>
<LazyMount when={bulkGroupOpen}>
<BulkAddToGroupModal
open={bulkGroupOpen}
count={selectedRowKeys.length}
groups={allGroups}
onOpenChange={setBulkGroupOpen}
onSubmit={async (group) => {
const msg = await bulkAddToGroup([...selectedRowKeys], group);
if (msg?.success) {
setSelectedRowKeys([]);
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={bulkAttachOpen}>
<BulkAttachInboundsModal
open={bulkAttachOpen}
+2 -31
View File
@@ -9,8 +9,6 @@ import type { ClientRecord } from '@/hooks/useClients';
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
}
interface SubLinksModalProps {
@@ -26,7 +24,6 @@ interface Row {
email: string;
subId: string;
link: string;
jsonLink: string;
}
export default function SubLinksModal({
@@ -40,7 +37,6 @@ export default function SubLinksModal({
const [messageApi, messageContextHolder] = message.useMessage();
const enabled = !!subSettings?.enable && !!subSettings?.subURI;
const jsonEnabled = !!subSettings?.subJsonEnable && !!subSettings?.subJsonURI;
const rows = useMemo<Row[]>(() => {
if (!enabled) return [];
@@ -54,21 +50,12 @@ export default function SubLinksModal({
email,
subId: c.subId,
link: subSettings!.subURI + c.subId,
jsonLink: jsonEnabled ? subSettings!.subJsonURI + c.subId : '',
});
}
return out;
}, [emails, clients, enabled, jsonEnabled, subSettings]);
}, [emails, clients, enabled, subSettings]);
const allText = useMemo(
() =>
rows
.map((r) =>
jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`,
)
.join('\n'),
[rows, jsonEnabled],
);
const allText = useMemo(() => rows.map((r) => `${r.email}\t${r.link}`).join('\n'), [rows]);
async function copy(text: string, label?: string) {
try {
@@ -129,22 +116,6 @@ export default function SubLinksModal({
},
];
if (jsonEnabled) {
columns.splice(2, 0, {
title: t('pages.clients.subJsonLinkColumn'),
dataIndex: 'jsonLink',
key: 'jsonLink',
ellipsis: true,
render: (link: string) => (
<Tooltip title={link} placement="topLeft">
<Typography.Text copyable={false} ellipsis>
{link}
</Typography.Text>
</Tooltip>
),
});
}
return (
<>
{messageContextHolder}
@@ -1,170 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ClientRecord } from '@/hooks/useClients';
interface GroupAddClientsModalProps {
open: boolean;
groupName: string | null;
candidates: ClientRecord[];
onClose: () => void;
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
}
interface ClientRow {
email: string;
comment: string;
enable: boolean;
currentGroup: string;
}
export default function GroupAddClientsModal({
open,
groupName,
candidates,
onClose,
onSubmit,
}: GroupAddClientsModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [saving, setSaving] = useState(false);
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState('');
const rows = useMemo<ClientRow[]>(
() =>
(candidates || [])
.map((c) => ({
email: (c.email || '').trim(),
comment: (c.comment || '').trim(),
enable: c.enable !== false,
currentGroup: (c.group || '').trim(),
}))
.filter((r) => r.email),
[candidates],
);
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.email.toLowerCase().includes(q) ||
r.comment.toLowerCase().includes(q) ||
r.currentGroup.toLowerCase().includes(q),
);
}, [rows, search]);
const columns: ColumnsType<ClientRow> = useMemo(
() => [
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
{
title: t('pages.clients.group'),
dataIndex: 'currentGroup',
key: 'currentGroup',
width: 140,
ellipsis: true,
render: (g: string) =>
g ? (
<Tag color="geekblue">{g}</Tag>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>
),
},
{
title: t('enable'),
dataIndex: 'enable',
key: 'enable',
width: 90,
render: (enabled: boolean) =>
enabled ? (
<Tag color="success">{t('enable')}</Tag>
) : (
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
),
},
],
[t],
);
async function submit() {
if (!groupName || selectedEmails.length === 0) return;
setSaving(true);
try {
const result = await onSubmit(selectedEmails);
if (!result) return;
const affected = result.affected ?? selectedEmails.length;
messageApi.success(t('pages.groups.addToGroupResult', { count: affected, name: groupName }));
onClose();
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onCancel={onClose}
onOk={submit}
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
okText={t('add')}
cancelText={t('cancel')}
title={t('pages.groups.addToGroupTitle', { name: groupName ?? '' })}
width={720}
>
{messageContextHolder}
<Typography.Paragraph type="secondary">
{t('pages.groups.addToGroupDesc')}
</Typography.Paragraph>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ maxWidth: 320 }}
/>
<Typography.Text type="secondary">
{t('pages.inbounds.attachClientsSelectedCount', {
selected: selectedEmails.length,
total: rows.length,
})}
</Typography.Text>
</Space>
{rows.length === 0 ? (
<Alert type="info" showIcon title={t('pages.groups.addToGroupEmpty')} />
) : (
<Table<ClientRow>
size="small"
rowKey="email"
columns={columns}
dataSource={filteredRows}
pagination={false}
scroll={{ y: 320 }}
rowSelection={{
selectedRowKeys: selectedEmails,
onChange: (keys) => setSelectedEmails(keys as string[]),
preserveSelectedRowKeys: true,
}}
/>
)}
</Space>
</Modal>
);
}
@@ -1,150 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ClientRecord } from '@/hooks/useClients';
interface GroupRemoveClientsModalProps {
open: boolean;
groupName: string | null;
members: ClientRecord[];
onClose: () => void;
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
}
interface ClientRow {
email: string;
comment: string;
enable: boolean;
}
export default function GroupRemoveClientsModal({
open,
groupName,
members,
onClose,
onSubmit,
}: GroupRemoveClientsModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [saving, setSaving] = useState(false);
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState('');
const rows = useMemo<ClientRow[]>(
() =>
(members || [])
.map((c) => ({
email: (c.email || '').trim(),
comment: (c.comment || '').trim(),
enable: c.enable !== false,
}))
.filter((r) => r.email),
[members],
);
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q),
);
}, [rows, search]);
const columns: ColumnsType<ClientRow> = useMemo(
() => [
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
{
title: t('enable'),
dataIndex: 'enable',
key: 'enable',
width: 90,
render: (enabled: boolean) =>
enabled ? (
<Tag color="success">{t('enable')}</Tag>
) : (
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
),
},
],
[t],
);
async function submit() {
if (!groupName || selectedEmails.length === 0) return;
setSaving(true);
try {
const result = await onSubmit(selectedEmails);
if (!result) return;
const affected = result.affected ?? selectedEmails.length;
messageApi.success(
t('pages.groups.removeFromGroupResult', { count: affected, name: groupName }),
);
onClose();
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onCancel={onClose}
onOk={submit}
okButtonProps={{ danger: true, disabled: selectedEmails.length === 0, loading: saving }}
okText={t('remove')}
cancelText={t('cancel')}
title={t('pages.groups.removeFromGroupTitle', { name: groupName ?? '' })}
width={680}
>
{messageContextHolder}
<Typography.Paragraph type="secondary">
{t('pages.groups.removeFromGroupDesc')}
</Typography.Paragraph>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ maxWidth: 320 }}
/>
<Typography.Text type="secondary">
{t('pages.inbounds.attachClientsSelectedCount', {
selected: selectedEmails.length,
total: rows.length,
})}
</Typography.Text>
</Space>
<Table<ClientRow>
size="small"
rowKey="email"
columns={columns}
dataSource={filteredRows}
pagination={false}
scroll={{ y: 280 }}
rowSelection={{
selectedRowKeys: selectedEmails,
onChange: (keys) => setSelectedEmails(keys as string[]),
preserveSelectedRowKeys: true,
}}
/>
</Space>
</Modal>
);
}
-708
View File
@@ -1,708 +0,0 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
ConfigProvider,
Dropdown,
Form,
Input,
Layout,
Modal,
Result,
Row,
Space,
Spin,
Statistic,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import type { MenuProps, TableColumnsType } from 'antd';
import {
ArrowDownOutlined,
ArrowUpOutlined,
ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
LinkOutlined,
MoreOutlined,
PieChartOutlined,
PlusOutlined,
RetweetOutlined,
TagsOutlined,
TeamOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { usePageTitle } from '@/hooks/usePageTitle';
import { useClients } from '@/hooks/useClients';
import { HttpUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import { keys } from '@/api/queryKeys';
import {
ClientRecordSchema,
GroupSummaryListSchema,
type ClientRecord,
type GroupSummary,
} from '@/schemas/client';
import { parseMsg } from '@/utils/zodValidate';
const ClientRecordListSchema = z
.array(ClientRecordSchema)
.nullable()
.transform((v) => v ?? []);
const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
const GroupAddClientsModal = lazy(() => import('./GroupAddClientsModal'));
const GroupRemoveClientsModal = lazy(() => import('./GroupRemoveClientsModal'));
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
async function fetchGroups(): Promise<GroupSummary[]> {
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load groups');
const validated = parseMsg(msg, GroupSummaryListSchema, 'clients/groups');
return validated.obj ?? [];
}
async function fetchEmailsForGroup(name: string): Promise<string[]> {
const msg = await HttpUtil.get<string[]>(
`/panel/api/clients/groups/${encodeURIComponent(name)}/emails`,
undefined,
{ silent: true },
);
if (!msg?.success || !Array.isArray(msg.obj)) return [];
return msg.obj;
}
export default function GroupsPage() {
usePageTitle();
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const queryClient = useQueryClient();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({
list: false,
});
const groupsQuery = useQuery({
queryKey: keys.clients.groups(),
queryFn: fetchGroups,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const loading = groupsQuery.isFetching;
const fetched = groupsQuery.data !== undefined || groupsQuery.isError;
const fetchError = groupsQuery.error ? (groupsQuery.error as Error).message : '';
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: keys.clients.root() });
}, [queryClient]);
const createMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const renameMut = useMutation({
mutationFn: (body: { oldName: string; newName: string }) =>
HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const deleteMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const groupResetMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [renameOpen, setRenameOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<GroupSummary | null>(null);
const [renameValue, setRenameValue] = useState('');
const [subLinksOpen, setSubLinksOpen] = useState(false);
const [adjustOpen, setAdjustOpen] = useState(false);
const [addClientsOpen, setAddClientsOpen] = useState(false);
const [removeClientsOpen, setRemoveClientsOpen] = useState(false);
const [groupEmails, setGroupEmails] = useState<string[]>([]);
const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
const allClientsQuery = useQuery<ClientRecord[]>({
queryKey: keys.clients.all(),
queryFn: async () => {
const msg = await HttpUtil.get('/panel/api/clients/list', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load clients');
const validated = parseMsg(msg, ClientRecordListSchema, 'clients/list');
return validated.obj ?? [];
},
enabled: addClientsOpen || removeClientsOpen || subLinksOpen,
staleTime: 30_000,
});
const allClients = allClientsQuery.data ?? [];
const totalGroups = groups.length;
const totalClients = useMemo(
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
[groups],
);
const totalTraffic = useMemo(
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups],
);
const totalUpload = useMemo(() => groups.reduce((acc, g) => acc + (g.up || 0), 0), [groups]);
const totalDownload = useMemo(() => groups.reduce((acc, g) => acc + (g.down || 0), 0), [groups]);
function openCreate() {
setCreateName('');
setCreateOpen(true);
}
async function confirmCreate() {
const name = createName.trim();
if (!name) return;
if (groups.some((g) => g.name.toLowerCase() === name.toLowerCase())) {
messageApi.error(t('pages.groups.renameCollision', { name }));
return;
}
const msg = await createMut.mutateAsync({ name });
if (msg?.success) {
messageApi.success(t('pages.groups.createSuccess', { name }));
setCreateOpen(false);
}
}
function openRename(g: GroupSummary) {
setRenameTarget(g);
setRenameValue(g.name);
setRenameOpen(true);
}
async function confirmRename() {
if (!renameTarget) return;
const next = renameValue.trim();
if (!next || next === renameTarget.name) {
setRenameOpen(false);
return;
}
if (
groups.some(
(g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name,
)
) {
messageApi.error(t('pages.groups.renameCollision', { name: next }));
return;
}
const msg = await renameMut.mutateAsync({ oldName: renameTarget.name, newName: next });
if (msg?.success) {
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
messageApi.success(t('pages.groups.renameSuccess', { count: affected }));
setRenameOpen(false);
}
}
function onDelete(g: GroupSummary) {
modal.confirm({
title: t('pages.groups.deleteConfirmTitle', { name: g.name }),
content: t('pages.groups.deleteConfirmContent', { count: g.clientCount }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await deleteMut.mutateAsync({ name: g.name });
if (msg?.success) {
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
messageApi.success(t('pages.groups.deleteSuccess', { count: affected }));
}
},
});
}
async function openSubLinksFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setGroupEmails(emails);
setSubLinksOpen(true);
}
async function openAdjustFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setGroupEmails(emails);
setAdjustOpen(true);
}
function openAddClientsFor(g: GroupSummary) {
setGroupForAction(g);
setAddClientsOpen(true);
}
function openRemoveClientsFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setRemoveClientsOpen(true);
}
function onDeleteClients(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
modal.confirm({
title: t('pages.groups.deleteClientsConfirmTitle', { name: g.name }),
content: t('pages.groups.deleteClientsConfirmContent', { count: g.clientCount }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) return;
const msg = await bulkDelete(emails);
if (msg?.success) {
const ok = msg.obj?.deleted ?? 0;
const skipped = msg.obj?.skipped ?? [];
const failed = skipped.length;
if (failed === 0) {
messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok }));
} else {
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
messageApi.warning(
firstError
? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
: t('pages.groups.deleteClientsMixed', { ok, failed }),
);
}
}
},
});
}
function onResetTraffic(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
modal.confirm({
title: t('pages.groups.resetConfirmTitle', { name: g.name }),
content: t('pages.groups.resetConfirmContent'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await groupResetMut.mutateAsync({ name: g.name });
if (msg?.success) {
messageApi.success(t('pages.groups.resetSuccess', { name: g.name }));
}
},
});
}
function rowActions(row: GroupSummary): MenuProps['items'] {
return [
{
key: 'subLinks',
icon: <LinkOutlined />,
label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
disabled: !row.clientCount,
onClick: () => openSubLinksFor(row),
},
{
key: 'adjust',
icon: <ClockCircleOutlined />,
label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
disabled: !row.clientCount,
onClick: () => openAdjustFor(row),
},
{
key: 'reset',
icon: <RetweetOutlined />,
label: t('pages.groups.resetTraffic'),
disabled: !row.clientCount,
onClick: () => onResetTraffic(row),
},
{
key: 'addClients',
icon: <UsergroupAddOutlined />,
label: t('pages.groups.addToGroup'),
onClick: () => openAddClientsFor(row),
},
{
key: 'rename',
icon: <EditOutlined />,
label: t('pages.groups.rename'),
onClick: () => openRename(row),
},
{ type: 'divider' },
{
key: 'removeClients',
icon: <UsergroupDeleteOutlined />,
label: t('pages.groups.removeFromGroup'),
danger: true,
disabled: !row.clientCount,
onClick: () => openRemoveClientsFor(row),
},
{
key: 'deleteClients',
icon: <DeleteOutlined />,
label: t('pages.groups.deleteClients'),
danger: true,
disabled: !row.clientCount,
onClick: () => onDeleteClients(row),
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: t('pages.groups.deleteGroupOnly'),
danger: true,
onClick: () => onDelete(row),
},
];
}
const columns: TableColumnsType<GroupSummary> = [
{
title: t('pages.clients.actions'),
key: 'actions',
width: 90,
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button
aria-label={t('more')}
size="small"
type="text"
style={{ fontSize: 16 }}
icon={<MoreOutlined />}
/>
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button
aria-label={t('pages.groups.rename')}
size="small"
type="text"
style={{ fontSize: 16 }}
icon={<EditOutlined />}
onClick={() => openRename(row)}
/>
</Tooltip>
</Space>
),
},
{
title: t('pages.groups.name'),
dataIndex: 'name',
key: 'name',
render: (name: string) => (
<Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>
{name}
</Tag>
),
},
{
title: t('pages.groups.clientCount'),
dataIndex: 'clientCount',
key: 'clientCount',
width: 180,
render: (count: number) => <span>{count || 0}</span>,
},
{
title: t('pages.groups.upload'),
dataIndex: 'up',
key: 'up',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{
title: t('pages.groups.download'),
dataIndex: 'down',
key: 'down',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{
title: t('pages.groups.trafficUsed'),
dataIndex: 'trafficUsed',
key: 'trafficUsed',
width: 160,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
];
const pageClass = useMemo(() => {
const classes = ['groups-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>
{t('refresh')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
<Col span={24}>
<Card size="small" hoverable className="summary-card">
<Row gutter={[16, isMobile ? 16 : 12]}>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalGroups')}
value={String(totalGroups)}
prefix={<TagsOutlined />}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalGroupedClients')}
value={String(totalClients)}
prefix={<TeamOutlined />}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalUpDown')}
value={0}
formatter={() => (
<span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}
{' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}
</span>
)}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalTraffic')}
value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<PieChartOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card
size="small"
hoverable
title={
<div className="card-toolbar">
<Button
aria-label={t('pages.groups.addGroup')}
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
>
{!isMobile && t('pages.groups.addGroup')}
</Button>
</div>
}
>
<Table<GroupSummary>
dataSource={groups}
columns={columns}
rowKey="name"
size="small"
pagination={false}
loading={loading}
locale={{
emptyText: (
<div className="card-empty">
<TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
/>
</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<Modal
open={createOpen}
title={t('pages.groups.addGroup')}
okText={t('create')}
cancelText={t('cancel')}
confirmLoading={createMut.isPending}
onCancel={() => setCreateOpen(false)}
onOk={confirmCreate}
destroyOnHidden
>
<Form layout="vertical">
<Form.Item label={t('pages.groups.name')}>
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onPressEnter={confirmCreate}
placeholder={t('pages.clients.groupPlaceholder')}
autoFocus
/>
</Form.Item>
</Form>
</Modal>
<Modal
open={renameOpen}
title={renameTarget ? t('pages.groups.renameTitle', { name: renameTarget.name }) : ''}
okText={t('save')}
cancelText={t('cancel')}
confirmLoading={renameMut.isPending}
onCancel={() => setRenameOpen(false)}
onOk={confirmRename}
destroyOnHidden
>
<Form layout="vertical">
<Form.Item label={t('pages.groups.name')}>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onPressEnter={confirmRename}
placeholder={t('pages.clients.groupPlaceholder')}
autoFocus
/>
</Form.Item>
</Form>
</Modal>
<LazyMount when={subLinksOpen}>
<SubLinksModal
open={subLinksOpen}
emails={groupEmails}
clients={allClients}
subSettings={subSettings}
onOpenChange={setSubLinksOpen}
/>
</LazyMount>
<LazyMount when={adjustOpen}>
<ClientBulkAdjustModal
open={adjustOpen}
count={groupEmails.length}
onOpenChange={setAdjustOpen}
onSubmit={async (addDays, addBytes) => {
const msg = await bulkAdjust(groupEmails, addDays, addBytes);
if (msg?.success) {
const obj = msg.obj ?? { adjusted: 0 };
messageApi.success(
t('pages.groups.adjustSuccess', {
count: obj.adjusted ?? 0,
name: groupForAction?.name ?? '',
}),
);
return obj;
}
return null;
}}
/>
</LazyMount>
<LazyMount when={addClientsOpen}>
<GroupAddClientsModal
open={addClientsOpen}
groupName={groupForAction?.name ?? null}
candidates={allClients.filter((c) => c.group !== groupForAction?.name)}
onClose={() => setAddClientsOpen(false)}
onSubmit={async (emails) => {
const msg = await bulkAddToGroup(emails, groupForAction?.name ?? '');
if (msg?.success) {
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={removeClientsOpen}>
<GroupRemoveClientsModal
open={removeClientsOpen}
groupName={groupForAction?.name ?? null}
members={allClients.filter((c) => c.group === groupForAction?.name)}
onClose={() => setRemoveClientsOpen(false)}
onSubmit={async (emails) => {
const msg = await bulkRemoveFromGroup(emails);
if (msg?.success) {
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
</Layout>
</ConfigProvider>
);
}
@@ -1,4 +1,4 @@
import { MuxForm } from '@/pages/xray/outbounds/transport';
import { MuxForm } from '@/lib/xray/forms/outbound/transport';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
@@ -1,4 +1,4 @@
import { SockoptForm } from '@/pages/xray/outbounds/transport';
import { SockoptForm } from '@/lib/xray/forms/outbound/transport';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
+5 -19
View File
@@ -30,6 +30,7 @@ import {
genInboundLinks,
genWireguardLinks,
preferPublicHost,
shareHostForNode,
} from '@/lib/xray/inbound-link';
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
import { Protocols } from '@/schemas/primitives';
@@ -53,7 +54,6 @@ const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
type RowAction =
| 'edit'
@@ -68,7 +68,6 @@ type RowAction =
| 'attachClients'
| 'attachExisting'
| 'detachClients'
| 'addToGroup'
| 'clone';
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -165,9 +164,6 @@ export default function InboundsPage() {
const [detachOpen, setDetachOpen] = useState(false);
const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
const [groupOpen, setGroupOpen] = useState(false);
const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
const [cloneOpen, setCloneOpen] = useState(false);
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
@@ -192,7 +188,10 @@ export default function InboundsPage() {
const hostOverrideFor = useCallback(
(dbInbound: DBInbound | null) => {
if (!dbInbound || dbInbound.nodeId == null) return '';
return nodesById.get(dbInbound.nodeId)?.address || '';
// Agent nodes store the literal placeholder "agent" in address; the
// resolved host lives in the node's ipv4/ipv6 (shareHostForNode).
// Reading .address raw put "@agent:<port>" into exported links.
return shareHostForNode(nodesById.get(dbInbound.nodeId));
},
[nodesById],
);
@@ -622,7 +621,6 @@ export default function InboundsPage() {
'clipboard',
'clone',
'attachClients',
'addToGroup',
];
let target = dbInbound;
if (hydratingKeys.includes(key)) {
@@ -672,10 +670,6 @@ export default function InboundsPage() {
setDetachSource(target);
setDetachOpen(true);
break;
case 'addToGroup':
setGroupSource(target);
setGroupOpen(true);
break;
case 'clone':
confirmClone(target);
break;
@@ -848,14 +842,6 @@ export default function InboundsPage() {
source={detachSource}
/>
</LazyMount>
<LazyMount when={groupOpen}>
<AddClientsToGroupModal
open={groupOpen}
onClose={() => setGroupOpen(false)}
onAdded={refresh}
source={groupSource}
/>
</LazyMount>
<LazyMount when={cloneOpen}>
<CloneInboundModal
open={cloneOpen}
@@ -1,65 +0,0 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { HttpUtil } from '@/utils';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
const BulkAddToGroupModal = lazy(() => import('@/pages/clients/BulkAddToGroupModal'));
interface AddClientsToGroupModalProps {
open: boolean;
source: DBInbound | null;
onClose: () => void;
onAdded?: () => void;
}
function readClientEmails(settings: unknown): string[] {
const parsed = coerceInboundJsonField(settings) as { clients?: Array<{ email?: string }> };
const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
}
export default function AddClientsToGroupModal({
open,
source,
onClose,
onAdded,
}: AddClientsToGroupModalProps) {
const [groups, setGroups] = useState<string[]>([]);
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
if (cancelled) return;
const list = Array.isArray(msg?.obj) ? (msg.obj as Array<{ name?: string }>) : [];
setGroups(list.map((g) => g?.name || '').filter(Boolean));
})();
return () => {
cancelled = true;
};
}, [open]);
return (
<BulkAddToGroupModal
open={open}
count={emails.length}
groups={groups}
onOpenChange={(o) => {
if (!o) onClose();
}}
onSubmit={async (group) => {
const msg = await HttpUtil.post(
'/panel/api/clients/groups/bulkAdd',
{ emails, group },
{ headers: { 'Content-Type': 'application/json' } },
);
if (!msg?.success) return null;
onAdded?.();
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}}
/>
);
}
@@ -0,0 +1,173 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormContext, useWatch } from 'react-hook-form';
import { Alert, Button, Form, Input, InputNumber, Radio, Select, Space } from 'antd';
import { FormField } from '@/components/form/rhf';
import { HttpUtil } from '@/utils';
import type { ForwardingSpec } from '@/schemas/forms/inbound-form';
interface ForwardTarget {
id: number;
remark: string;
tag: string;
protocol: string;
listen: string;
port: number;
enable: boolean;
nodeId: number | null;
nodeName: string;
}
// Parse a landing link into manual-forwarding fields. Accepts the plain
// URL form (socks5://user:pass@host:port, also socks:// and http://) and
// the whole-link base64 variant common in share links.
export function parseLandingLink(raw: string): Partial<ForwardingSpec> | null {
const link = raw.trim();
if (!link) return null;
const tryUrl = (s: string): Partial<ForwardingSpec> | null => {
try {
const u = new URL(s);
if (!['socks5:', 'socks:', 'http:', 'https:'].includes(u.protocol)) return null;
const port = u.port ? parseInt(u.port, 10) : 0;
if (!u.hostname || !port) return null;
return {
protocol: u.protocol === 'http:' || u.protocol === 'https:' ? 'http' : 'socks5',
address: u.hostname,
port,
username: decodeURIComponent(u.username || ''),
password: decodeURIComponent(u.password || ''),
};
} catch {
return null;
}
};
const direct = tryUrl(link);
if (direct) return direct;
try {
const decoded = atob(link.replace(/-/g, '+').replace(/_/g, '/'));
return tryUrl(decoded.includes('://') ? decoded : `socks5://${decoded}`);
} catch {
return null;
}
}
export default function ForwardingTab({ currentId }: { currentId?: number }) {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const mode = useWatch({ control, name: 'forwarding.mode' });
const landing = useWatch({ control, name: 'forwarding.landing' });
const [targets, setTargets] = useState<ForwardTarget[]>([]);
const [link, setLink] = useState('');
const [linkError, setLinkError] = useState('');
useEffect(() => {
let alive = true;
HttpUtil.get('/panel/api/inbounds/forwardTargets')
.then((msg) => {
if (alive && msg?.success && Array.isArray(msg.obj)) {
setTargets(msg.obj as ForwardTarget[]);
}
})
.catch(() => {});
return () => {
alive = false;
};
}, []);
const applyLink = () => {
const parsed = parseLandingLink(link);
if (!parsed) {
setLinkError(t('pages.inbounds.forwarding.importFailed'));
return;
}
setLinkError('');
const opts = { shouldDirty: true, shouldValidate: true };
setValue('forwarding.protocol', parsed.protocol ?? 'socks5', opts);
setValue('forwarding.address', parsed.address ?? '', opts);
setValue('forwarding.port', parsed.port ?? 0, opts);
setValue('forwarding.username', parsed.username ?? '', opts);
setValue('forwarding.password', parsed.password ?? '', opts);
};
return (
<>
<FormField name="forwarding.mode" label={t('pages.inbounds.forwarding.mode')}>
<Radio.Group
options={[
{ value: 'direct', label: t('pages.inbounds.forwarding.direct') },
{ value: 'forward', label: t('pages.inbounds.forwarding.forward') },
]}
/>
</FormField>
{mode === 'forward' && (
<>
<FormField name="forwarding.landing" label={t('pages.inbounds.forwarding.landing')}>
<Radio.Group
options={[
{ value: 'managed', label: t('pages.inbounds.forwarding.managed') },
{ value: 'manual', label: t('pages.inbounds.forwarding.manual') },
]}
/>
</FormField>
{landing === 'managed' ? (
<FormField
name="forwarding.targetInboundId"
label={t('pages.inbounds.forwarding.target')}
>
<Select
showSearch
placeholder={t('pages.inbounds.forwarding.targetPlaceholder')}
options={targets
.filter((x) => x.id !== currentId)
.map((x) => ({
value: x.id,
disabled: !x.enable,
label: `${x.nodeName || t('pages.inbounds.localPanel')} · ${x.remark || x.tag} (${x.listen || '*'}:${x.port})${x.enable ? '' : ` (${t('pages.inbounds.forwarding.targetDisabled')})`}`,
}))}
/>
</FormField>
) : (
<>
<FormField name="forwarding.address" label={t('pages.inbounds.forwarding.address')}>
<Input placeholder="10.240.64.2" />
</FormField>
<FormField name="forwarding.port" label={t('pages.inbounds.forwarding.port')}>
<InputNumber min={1} max={65535} />
</FormField>
<FormField name="forwarding.protocol" label={t('pages.inbounds.forwarding.protocol')}>
<Select
options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP' },
]}
/>
</FormField>
<FormField name="forwarding.username" label={t('pages.inbounds.forwarding.username')}>
<Input autoComplete="off" />
</FormField>
<FormField name="forwarding.password" label={t('pages.inbounds.forwarding.password')}>
<Input.Password autoComplete="off" />
</FormField>
<Form.Item label={t('pages.inbounds.forwarding.importLink')}>
<Space.Compact style={{ width: '100%' }}>
<Input
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder={t('pages.inbounds.forwarding.importPlaceholder')}
/>
<Button onClick={applyLink}>{t('pages.inbounds.forwarding.importApply')}</Button>
</Space.Compact>
{linkError && <div style={{ color: '#ff4d4f', marginTop: 4 }}>{linkError}</div>}
</Form.Item>
</>
)}
<Alert type="info" showIcon message={t('pages.inbounds.forwarding.help')} />
</>
)}
</>
);
}
@@ -82,6 +82,7 @@ import { useSecurityActions } from './useSecurityActions';
import { useInboundFallbacks } from './useInboundFallbacks';
import FallbacksCard from './FallbacksCard';
import SniffingTab from './SniffingTab';
import ForwardingTab from './ForwardingTab';
import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
@@ -150,6 +151,7 @@ function firstRhfValidationIssue(
function tabForValidationPath(path: PropertyKey[]): string {
if (path[0] === 'settings') return 'protocol';
if (path[0] === 'sniffing') return 'sniffing';
if (path[0] === 'forwarding') return 'forwarding';
if (path[0] === 'streamSettings') {
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
return 'security';
@@ -618,7 +620,6 @@ export default function InboundFormModal({
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
<Select
showSearch
disabled={mode === 'edit'}
placeholder={t('pages.inbounds.localPanel')}
allowClear
options={selectableNodes.map((n) => ({
@@ -1079,6 +1080,10 @@ export default function InboundFormModal({
const sniffingTab = <SniffingTab />;
const forwardingTab = (
<ForwardingTab currentId={mode === 'edit' && dbInbound ? dbInbound.id : undefined} />
);
return (
<>
{messageContextHolder}
@@ -1112,6 +1117,12 @@ export default function InboundFormModal({
children: basicTab,
forceRender: true,
},
{
key: 'forwarding',
label: t('pages.inbounds.forwardingTab'),
children: forwardingTab,
forceRender: true,
},
...((
[
Protocols.VLESS,
@@ -54,7 +54,6 @@ export default function InboundInfoModal({
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [clientIpsArray, setClientIpsArray] = useState<string[]>([]);
const [clientIpsText, setClientIpsText] = useState('');
@@ -198,12 +197,8 @@ export default function InboundInfoModal({
if (clientSet?.subId) {
setSubLink((subSettings?.subURI || '') + clientSet.subId);
setSubJsonLink(
subSettings?.subJsonEnable ? (subSettings?.subJsonURI || '') + clientSet.subId : '',
);
} else {
setSubLink('');
setSubJsonLink('');
}
setClientIpsArray([]);
@@ -575,29 +570,6 @@ export default function InboundInfoModal({
{subLink}
</a>
</div>
{subSettings?.subJsonEnable && subJsonLink && (
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">JSON</Tag>
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyText(subJsonLink, t)}
/>
</Tooltip>
</div>
<a
href={subJsonLink}
target="_blank"
rel="noopener noreferrer"
className="link-panel-anchor"
>
{subJsonLink}
</a>
</div>
)}
</>
)}
</>
@@ -180,3 +180,11 @@
padding: 4px;
}
}
.forwarding-tag {
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
@@ -137,11 +137,17 @@ export default function InboundList({
if (ok) setSelectedRowKeys([]);
}, [onBulkDelete, selectedRowKeys]);
const inboundsById = useMemo(
() => new Map(dbInbounds.map((ib) => [ib.id, ib] as const)),
[dbInbounds],
);
const columns = useInboundColumns({
hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode,
nodesById,
inboundsById,
clientCount,
inboundSpeed,
subEnable,
@@ -10,7 +10,6 @@ import {
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
TagsOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
@@ -91,11 +90,6 @@ export function buildRowActionsMenu({
icon: <UsergroupDeleteOutlined />,
label: t('pages.inbounds.detachClients'),
});
items.push({
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.inbounds.addClientsToGroup'),
});
items.push({ type: 'divider' });
items.push({
key: 'delAllClients',
@@ -90,6 +90,8 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
case 'mtproto':
case 'wireguard':
case 'amneziawg':
case 'socks':
case 'mixed':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
@@ -104,3 +106,26 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
}
return false;
}
// Landing label for the list-row forwarding chip: the managed target's
// remark (falling back to its tag), or the manual endpoint address:port.
// Returns null when the inbound does not forward, so the caller renders
// no chip. The stored spec rides the row as a JSON string.
export function forwardingLandingLabel(
record: DBInboundRecord,
inboundsById: Map<number, DBInboundRecord>,
): string | null {
const spec = coerceInboundJsonField(record.forwarding) as {
mode?: string;
landing?: string;
targetInboundId?: number;
address?: string;
port?: number;
};
if (spec.mode !== 'forward') return null;
if (spec.landing === 'manual') {
return spec.address ? `${spec.address}:${spec.port ?? 0}` : 'SK5';
}
const target = spec.targetInboundId ? inboundsById.get(spec.targetInboundId) : undefined;
return target?.remark || target?.tag || 'SK5';
}
@@ -32,8 +32,10 @@ export interface DBInboundRecord extends ProtocolFlags {
expiryTime: number;
_expiryTime: { valueOf(): number } | null;
nodeId?: number | null;
tag?: string;
settings: unknown;
streamSettings: unknown;
forwarding?: unknown;
}
export interface ClientCountEntry {
@@ -17,6 +17,7 @@ import {
} from '@/components/utility/speedTagStyle';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import {
forwardingLandingLabel,
readStreamHints,
networkLabel,
networkL4,
@@ -31,6 +32,7 @@ interface UseInboundColumnsParams {
hasAnySubSortIndex: boolean;
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
inboundsById: Map<number, DBInboundRecord>;
clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>;
subEnable: boolean;
@@ -45,6 +47,7 @@ export function useInboundColumns({
hasAnySubSortIndex,
hasActiveNode,
nodesById,
inboundsById,
clientCount,
inboundSpeed,
subEnable,
@@ -257,6 +260,17 @@ export function useInboundColumns({
</Tag>,
);
}
const fwdLabel = forwardingLandingLabel(record, inboundsById);
if (fwdLabel) {
const text = `${t('pages.inbounds.forwardingTab')} → ${fwdLabel}`;
tags.push(
<Tooltip key="fwd" title={text}>
<Tag color="volcano" className="forwarding-tag">
{text}
</Tag>
</Tooltip>,
);
}
return <div className="protocol-tags">{tags}</div>;
},
},
@@ -453,6 +467,7 @@ export function useInboundColumns({
hasAnySubSortIndex,
hasActiveNode,
nodesById,
inboundsById,
clientCount,
inboundSpeed,
subEnable,
+1 -21
View File
@@ -55,7 +55,6 @@ export default function QrCodeModal({
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([]);
// Building the links is a pure function of the props, so it runs during
@@ -145,13 +144,10 @@ export default function QrCodeModal({
const subId = client?.subId;
let nextSub = '';
let nextSubJson = '';
if (subSettings?.enable && subId) {
nextSub = (subSettings.subURI || '') + subId;
nextSubJson = subSettings.subJsonEnable ? (subSettings.subJsonURI || '') + subId : '';
}
setSubLink(nextSub);
setSubJsonLink(nextSubJson);
}
const qrItems = useMemo<QrItem[]>(() => {
@@ -159,13 +155,6 @@ export default function QrCodeModal({
if (subLink) {
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
}
if (subJsonLink) {
items.push({
key: 'sub-json',
header: `${t('subscription.title')} (JSON)`,
value: subJsonLink,
});
}
links.forEach((link, idx) => {
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
});
@@ -202,16 +191,7 @@ export default function QrCodeModal({
}
});
return items;
}, [
subLink,
subJsonLink,
links,
wireguardConfigs,
wireguardLinks,
amneziawgConfigs,
amneziawgLinks,
t,
]);
}, [subLink, links, wireguardConfigs, wireguardLinks, amneziawgConfigs, amneziawgLinks, t]);
const collapseItems: CollapseProps['items'] = useMemo(
() =>
+2 -11
View File
@@ -20,8 +20,6 @@ export interface SubSettings {
enable: boolean;
subTitle: string;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
// Configured public host (Sub Domain, else Web Domain) used as the share/QR
// link host when the panel is reached on a loopback address. Empty if neither
// is set.
@@ -179,23 +177,19 @@ export function useInbounds() {
const tgBotEnable = !!defaults.tgBotEnable;
const ipLimitEnable = !!defaults.ipLimitEnable;
const pageSize = defaults.pageSize ?? 0;
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
const datepicker = (defaults.datepicker as 'gregorian') || 'gregorian';
const subSettings: SubSettings = useMemo(
() => ({
enable: !!defaults.subEnable,
subTitle: defaults.subTitle || '',
subURI: defaults.subURI || '',
subJsonURI: defaults.subJsonURI || '',
subJsonEnable: !!defaults.subJsonEnable,
publicHost: defaults.subDomain || defaults.webDomain || '',
}),
[
defaults.subEnable,
defaults.subTitle,
defaults.subURI,
defaults.subJsonURI,
defaults.subJsonEnable,
defaults.subDomain,
defaults.webDomain,
],
@@ -392,16 +386,13 @@ export function useInbounds() {
// Invalidate at the inbounds root so both `slim` (this page's list)
// and `options` (the Clients page's inbound picker) refetch. Without
// the options bucket, a freshly-created inbound stays invisible in
// the client add/edit modal until a full page reload. The xray config
// response carries inboundTags for the routing-rule tag picker, so it
// needs invalidating too or that list stays stale until a hard refresh.
// the client add/edit modal until a full page reload.
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
]);
}, [queryClient]);
@@ -1,17 +0,0 @@
.awglog-events-title {
margin-top: 14px;
font-size: 12px;
font-weight: 600;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.awglog-event-line {
padding: 2px 0;
word-break: break-word;
}
.xraylog-table .log-row-offline {
opacity: 0.6;
}
@@ -1,254 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil, SizeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import type { AmneziaWGLogs } from '@/generated/types';
import './XrayLogModal.css';
import './AmneziaWGLogModal.css';
interface AmneziaWGLogModalProps {
open: boolean;
onClose: () => void;
}
const AUTO_UPDATE_INTERVAL = 5000;
function shortTime(value?: number): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('50');
const [filter, setFilter] = useState('');
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<Partial<AmneziaWGLogs>>({});
const peers = useMemo(() => logs.peers ?? [], [logs.peers]);
const events = useMemo(() => logs.events ?? [], [logs.events]);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<AmneziaWGLogs>(`/panel/api/server/amneziawglogs/${rows}`, {
filter,
});
if (msg?.success) setLogs(msg.obj || {});
await PromiseUtil.sleep(300);
} finally {
setLoading(false);
}
}, [rows, filter]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open ? `${rows}|${filter}` : null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
if (open) void runRefresh();
}, [open, rows, filter, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
function fullDate(value?: number): string {
return value ? IntlUtil.formatDate(value, datepicker) : '';
}
function download() {
const peerLines = peers.map((p) => {
const at = p.handshake ? new Date(p.handshake).toISOString() : 'never';
return `${at} IFACE=${p.interface || ''} INBOUND=${p.tag || ''} EMAIL=${p.email || ''} ENDPOINT=${p.endpoint || '-'} ALLOWEDIPS=${p.allowedIPs || ''} UP=${p.up ?? 0} DOWN=${p.down ?? 0} ONLINE=${p.online ? 'yes' : 'no'}`;
});
FileManager.downloadTextFile([...peerLines, '', ...events].join('\n'), 'amneziawg.log');
}
return (
<Modal
open={open}
footer={null}
width={isMobile ? '100vw' : '80vw'}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
className={isMobile ? 'xraylog-modal-mobile' : undefined}
onCancel={onClose}
title={
<>
{t('pages.index.amneziawgLogs')}
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
}
>
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Select
value={rows}
size="small"
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
]}
/>
</Form.Item>
<Form.Item label={t('filter')} className="filter-item">
<Input
value={filter}
size="small"
onChange={(e) => setFilter(e.target.value)}
onKeyUp={(e) => {
if (e.key === 'Enter') refresh();
}}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</Form.Item>
</Form>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{peers.length === 0 ? (
<div className="log-empty">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('pages.index.amneziawgNoPeers')}
/>
</div>
) : isMobile ? (
peers.map((peer, idx) => (
<div key={idx} className="log-card">
<div className="log-card-head">
<span className="log-time" title={fullDate(peer.handshake)}>
{shortTime(peer.handshake) || '—'}
</span>
<Tag color={peer.online ? 'green' : 'default'} className="log-event-tag">
{peer.online ? t('online') : t('pages.index.amneziawgIdle')}
</Tag>
</div>
<div className="log-route">
<span className="log-addr">{peer.endpoint || '—'}</span>
<span className="log-arrow">→</span>
<span className="log-addr">{peer.allowedIPs}</span>
</div>
<div className="log-meta">
<span className="log-meta-pair">
<span className="log-meta-key">iface</span>
<span className="log-meta-val">{peer.interface}</span>
</span>
<span className="log-meta-pair">
<span className="log-meta-key">inbound</span>
<span className="log-meta-val">{peer.tag}</span>
</span>
{peer.email && (
<span className="log-meta-pair">
<span className="log-meta-key">email</span>
<span className="log-meta-val">{peer.email}</span>
</span>
)}
<span className="log-meta-pair">
<span className="log-meta-key">↑↓</span>
<span className="log-meta-val">
{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}
</span>
</span>
</div>
</div>
))
) : (
<table className="xraylog-table">
<thead>
<tr>
<th>{t('pages.index.amneziawgHandshake')}</th>
<th>{t('pages.index.amneziawgInterface')}</th>
<th>{t('pages.index.amneziawgInbound')}</th>
<th>Email</th>
<th>{t('pages.index.amneziawgEndpoint')}</th>
<th>{t('pages.clients.amneziaWgAllowedIPs')}</th>
<th>↑ / ↓</th>
</tr>
</thead>
<tbody>
{peers.map((peer, idx) => (
<tr key={idx} className={peer.online ? undefined : 'log-row-offline'}>
<td>
<b>{fullDate(peer.handshake) || '—'}</b>
</td>
<td>{peer.interface}</td>
<td>{peer.tag}</td>
<td>{peer.email}</td>
<td>{peer.endpoint || '—'}</td>
<td>{peer.allowedIPs}</td>
<td>{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="awglog-events-title">{t('pages.index.amneziawgEvents')}</div>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{events.length === 0 ? (
<div className="log-empty">{t('pages.index.amneziawgNoEvents')}</div>
) : (
events.map((line, idx) => (
<div key={idx} className="awglog-event-line">
{line}
</div>
))
)}
</div>
</Modal>
);
}
-228
View File
@@ -1,228 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
interface GeodataAssetRow {
url: string;
file: string;
}
interface GeodataSectionProps {
active: boolean;
onBusy: (e: { busy: boolean; tip?: string }) => void;
onClose: () => void;
}
const DEFAULT_CRON = '0 4 * * *';
// Xray resolves `file` inside its asset directory; plain file names only.
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
function fileNameFromUrl(url: string): string {
try {
const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
return FILE_NAME_PATTERN.test(seg) ? seg : '';
} catch {
return '';
}
}
export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [cron, setCron] = useState(DEFAULT_CRON);
const [outbound, setOutbound] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<GeodataAssetRow[]>([]);
const [outboundTags, setOutboundTags] = useState<string[]>([]);
const [template, setTemplate] = useState<Record<string, unknown> | null>(null);
const outboundTestUrlRef = useRef('');
const load = useCallback(async () => {
try {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success || typeof msg.obj !== 'string') return;
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const next = (payload.xraySetting || {}) as Record<string, unknown>;
setTemplate(next);
outboundTestUrlRef.current =
typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
const geodata = (next.geodata || {}) as Record<string, unknown>;
const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
setRows(
assets
.filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
.map((a) => ({ url: String(a.url ?? ''), file: String(a.file ?? '') })),
);
setCron(typeof geodata.cron === 'string' && geodata.cron ? geodata.cron : DEFAULT_CRON);
setOutbound(
typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
);
// Download outbound candidates: template outbounds + subscription outbounds.
// Skip blackhole outbounds — routing a download through one just drops it.
const tags = new Set<string>();
const outbounds = Array.isArray(next.outbounds) ? next.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
if (rec.protocol === 'blackhole') continue;
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags)
? payload.subscriptionOutboundTags
: [];
for (const tag of subTags) {
if (typeof tag === 'string' && tag) tags.add(tag);
}
setOutboundTags([...tags]);
} finally {
setLoading(false);
}
}, []);
const [wasActive, setWasActive] = useState(false);
if (active !== wasActive) {
setWasActive(active);
if (active) setLoading(true);
}
useEffect(() => {
if (active) void load();
}, [active, load]);
function setRow(index: number, patch: Partial<GeodataAssetRow>) {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
}
function onUrlBlur(index: number) {
setRows((prev) =>
prev.map((r, i) => (i === index && !r.file ? { ...r, file: fileNameFromUrl(r.url) } : r)),
);
}
function save() {
if (!template) return;
const assets = rows
.map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
.filter((r) => r.url || r.file);
for (const a of assets) {
// Xray's geodata downloader accepts HTTPS URLs only.
if (!/^https:\/\/\S+$/i.test(a.url)) {
messageApi.error(t('pages.index.geodataInvalidUrl'));
return;
}
if (!FILE_NAME_PATTERN.test(a.file)) {
messageApi.error(t('pages.index.geodataInvalidFile'));
return;
}
}
const cronValue = cron.trim();
if (assets.length > 0 && cronValue && cronValue.split(/\s+/).length !== 5) {
messageApi.error(t('pages.index.geodataInvalidCron'));
return;
}
modal.confirm({
title: t('pages.index.geodataConfirmTitle'),
content: t('pages.index.geodataConfirmContent'),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
const next: Record<string, unknown> = { ...template };
if (assets.length === 0) {
delete next.geodata;
} else {
const geodata: Record<string, unknown> = { assets };
if (cronValue) geodata.cron = cronValue;
if (outbound) geodata.outbound = outbound;
next.geodata = geodata;
}
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
try {
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: JSON.stringify(next, null, 2),
outboundTestUrl: outboundTestUrlRef.current,
});
if (msg?.success) {
await HttpUtil.post('/panel/api/server/restartXrayService');
}
} finally {
onBusy({ busy: false });
}
},
});
}
return (
<div>
{modalContextHolder}
{messageContextHolder}
<Spin spinning={loading}>
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
<Form layout="vertical">
<Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}>
<Input
value={cron}
placeholder={DEFAULT_CRON}
onChange={(e) => setCron(e.target.value)}
/>
</Form.Item>
<Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}>
<Select
style={{ width: '100%' }}
allowClear
value={outbound}
onChange={(v) => setOutbound(v)}
options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
/>
</Form.Item>
</Form>
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
{rows.length === 0 && (
<Typography.Text type="secondary">{t('pages.index.geodataEmpty')}</Typography.Text>
)}
{rows.map((row, i) => (
<Space.Compact key={i} style={{ width: '100%' }}>
<Input
style={{ width: '60%' }}
placeholder="https://example.com/geosite_custom.dat"
value={row.url}
onChange={(e) => setRow(i, { url: e.target.value })}
onBlur={() => onUrlBlur(i)}
/>
<Input
style={{ width: '40%' }}
placeholder={t('pages.index.geodataFile')}
value={row.file}
onChange={(e) => setRow(i, { file: e.target.value })}
/>
<Button
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/>
</Space.Compact>
))}
<div className="actions-row">
<Button
icon={<PlusOutlined />}
onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
>
{t('pages.index.geodataAddFile')}
</Button>
<Button type="primary" onClick={save} disabled={loading || !template}>
{t('pages.index.geodataSaveRestart')}
</Button>
</div>
</Space>
</Spin>
</div>
);
}
-54
View File
@@ -38,53 +38,6 @@
flex-wrap: wrap;
}
.ov-state {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border: 1px solid var(--ov-line);
border-radius: 999px;
font-size: 13px;
color: var(--ant-color-text);
}
.ov-state-dot {
position: relative;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex: none;
}
.ov-state[data-state='running'] .ov-state-dot::after {
content: '';
position: absolute;
inset: -1px;
border-radius: 50%;
border: 1px solid currentColor;
animation: ovPulse 1.6s infinite ease-out;
}
@keyframes ovPulse {
0% {
transform: scale(0.9);
opacity: 0.5;
}
100% {
transform: scale(2.4);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ov-state[data-state='running'] .ov-state-dot::after {
animation: none;
}
}
.ov-state-version,
.ov-panel-version {
padding: 0;
border: 0;
@@ -95,8 +48,6 @@
transition: color 0.2s;
}
.ov-state-version:hover,
.ov-state-version:focus-visible,
.ov-panel-version:hover,
.ov-panel-version:focus-visible {
color: var(--ant-color-primary);
@@ -112,11 +63,6 @@
margin-inline-end: 0;
}
.ov-error-detail {
white-space: pre-wrap;
word-break: break-word;
}
.ov-bar-actions {
margin-inline-start: auto;
display: flex;

Some files were not shown because too many files have changed in this diff Show More