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
697 changed files with 33275 additions and 33815 deletions
+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/
@@ -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 />
</>
);
}
+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 -17
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,
@@ -37,17 +34,4 @@ export const keys = {
activeInbounds: () => ['clients', 'activeInbounds'] as const,
lastOnline: () => ['clients', 'lastOnline'] 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;
+19 -55
View File
@@ -3,33 +3,16 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSetting": {
"datepicker": "",
"expireDiff": 0,
"ipLimitAllowlist": "",
"outboundDownThreshold": 1,
"pageSize": 0,
"panelOutbound": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashPath": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEncrypt": false,
"subHideSettings": false,
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
@@ -37,7 +20,6 @@ export const EXAMPLES: Record<string, unknown> = {
"subProfileUrl": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
@@ -54,6 +36,7 @@ export const EXAMPLES: Record<string, unknown> = {
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trafficStatsSwitches": "",
"trustedProxyCIDRs": "",
"warpUpdateInterval": 0,
"webBasePath": "",
@@ -70,33 +53,16 @@ export const EXAMPLES: Record<string, unknown> = {
"hasNordSecret": false,
"hasTgBotToken": false,
"hasWarpSecret": false,
"ipLimitAllowlist": "",
"outboundDownThreshold": 1,
"pageSize": 0,
"panelOutbound": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashPath": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEncrypt": false,
"subHideSettings": false,
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
@@ -104,7 +70,6 @@ export const EXAMPLES: Record<string, unknown> = {
"subProfileUrl": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
@@ -121,6 +86,7 @@ export const EXAMPLES: Record<string, unknown> = {
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trafficStatsSwitches": "",
"trustedProxyCIDRs": "",
"warpUpdateInterval": 0,
"webBasePath": "",
@@ -290,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": ""
@@ -548,7 +508,13 @@ export const EXAMPLES: Record<string, unknown> = {
},
"NodeView": {
"activeCount": 20,
"activeEmails": [
"a@example.com"
],
"address": "node.example.com",
"agentUpdateError": "",
"agentUpdateState": "failed",
"agentUpdateTarget": "A1.0.1",
"allowPrivateAddress": false,
"basePath": "/",
"clientCount": 25,
@@ -557,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,
@@ -567,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,
@@ -576,6 +550,9 @@ export const EXAMPLES: Record<string, unknown> = {
"netUp": 2097152,
"nodeType": "traditional",
"onlineCount": 5,
"onlineEmails": [
"a@example.com"
],
"outboundTag": "direct",
"panelVersion": "v3.x.x",
"parentGuid": "",
@@ -685,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": "",
+68 -239
View File
@@ -9,22 +9,11 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"ipLimitAllowlist": {
"type": "string"
},
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"pageSize": {
"maximum": 1000,
"minimum": 0,
"type": "integer"
},
"panelOutbound": {
"type": "string"
},
"remarkTemplate": {
"type": "string"
},
@@ -42,21 +31,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subCertFile": {
"type": "string"
},
"subClashAutoDetect": {
"type": "boolean"
},
"subClashEnable": {
"type": "boolean"
},
"subClashPath": {
"type": "string"
},
"subClashURI": {
"type": "string"
},
"subClashUserAgentRegex": {
"type": "string"
},
"subDomain": {
"type": "string"
},
@@ -69,33 +43,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings": {
"type": "boolean"
},
"subJsonAlwaysArray": {
"type": "boolean"
},
"subJsonAutoDetect": {
"type": "boolean"
},
"subJsonEnable": {
"type": "boolean"
},
"subJsonFinalMask": {
"type": "string"
},
"subJsonMux": {
"type": "string"
},
"subJsonObservatory": {
"type": "string"
},
"subJsonPath": {
"type": "string"
},
"subJsonURI": {
"type": "string"
},
"subJsonUserAgentRegex": {
"type": "string"
},
"subKeyFile": {
"type": "string"
},
@@ -120,9 +67,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subSupportUrl": {
"type": "string"
},
"subThemeDir": {
"type": "string"
},
"subTitle": {
"type": "string"
},
@@ -179,6 +123,10 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"trafficStatsSwitches": {
"description": "TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic\naccounting switches as one JSON string\n({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}),\ncarried verbatim so the settings page edits the same key the\nsing-box generator reads (settings.trafficStatsSwitches).",
"type": "string"
},
"trustedProxyCIDRs": {
"type": "string"
},
@@ -210,33 +158,16 @@ export const SCHEMAS: Record<string, unknown> = {
"required": [
"datepicker",
"expireDiff",
"ipLimitAllowlist",
"outboundDownThreshold",
"pageSize",
"panelOutbound",
"remarkTemplate",
"restartXrayOnClientDisable",
"sessionMaxAge",
"subAnnounce",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
"subClashPath",
"subClashURI",
"subClashUserAgentRegex",
"subDomain",
"subEnable",
"subEncrypt",
"subHideSettings",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
"subJsonObservatory",
"subJsonPath",
"subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile",
"subListen",
"subPath",
@@ -244,7 +175,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subProfileUrl",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
"subURI",
"subUpdates",
@@ -261,6 +191,7 @@ export const SCHEMAS: Record<string, unknown> = {
"tgRunTime",
"timeLocation",
"trafficDiff",
"trafficStatsSwitches",
"trustedProxyCIDRs",
"warpUpdateInterval",
"webBasePath",
@@ -293,22 +224,11 @@ export const SCHEMAS: Record<string, unknown> = {
"hasWarpSecret": {
"type": "boolean"
},
"ipLimitAllowlist": {
"type": "string"
},
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"pageSize": {
"maximum": 1000,
"minimum": 0,
"type": "integer"
},
"panelOutbound": {
"type": "string"
},
"remarkTemplate": {
"type": "string"
},
@@ -326,21 +246,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subCertFile": {
"type": "string"
},
"subClashAutoDetect": {
"type": "boolean"
},
"subClashEnable": {
"type": "boolean"
},
"subClashPath": {
"type": "string"
},
"subClashURI": {
"type": "string"
},
"subClashUserAgentRegex": {
"type": "string"
},
"subDomain": {
"type": "string"
},
@@ -353,33 +258,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings": {
"type": "boolean"
},
"subJsonAlwaysArray": {
"type": "boolean"
},
"subJsonAutoDetect": {
"type": "boolean"
},
"subJsonEnable": {
"type": "boolean"
},
"subJsonFinalMask": {
"type": "string"
},
"subJsonMux": {
"type": "string"
},
"subJsonObservatory": {
"type": "string"
},
"subJsonPath": {
"type": "string"
},
"subJsonURI": {
"type": "string"
},
"subJsonUserAgentRegex": {
"type": "string"
},
"subKeyFile": {
"type": "string"
},
@@ -404,9 +282,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subSupportUrl": {
"type": "string"
},
"subThemeDir": {
"type": "string"
},
"subTitle": {
"type": "string"
},
@@ -463,6 +338,10 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"trafficStatsSwitches": {
"description": "TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic\naccounting switches as one JSON string\n({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}),\ncarried verbatim so the settings page edits the same key the\nsing-box generator reads (settings.trafficStatsSwitches).",
"type": "string"
},
"trustedProxyCIDRs": {
"type": "string"
},
@@ -498,33 +377,16 @@ export const SCHEMAS: Record<string, unknown> = {
"hasNordSecret",
"hasTgBotToken",
"hasWarpSecret",
"ipLimitAllowlist",
"outboundDownThreshold",
"pageSize",
"panelOutbound",
"remarkTemplate",
"restartXrayOnClientDisable",
"sessionMaxAge",
"subAnnounce",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
"subClashPath",
"subClashURI",
"subClashUserAgentRegex",
"subDomain",
"subEnable",
"subEncrypt",
"subHideSettings",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
"subJsonObservatory",
"subJsonPath",
"subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile",
"subListen",
"subPath",
@@ -532,7 +394,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subProfileUrl",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
"subURI",
"subUpdates",
@@ -549,6 +410,7 @@ export const SCHEMAS: Record<string, unknown> = {
"tgRunTime",
"timeLocation",
"trafficDiff",
"trafficStatsSwitches",
"trustedProxyCIDRs",
"warpUpdateInterval",
"webBasePath",
@@ -1217,32 +1079,6 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"GeodataTokenIssue": {
"description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
"properties": {
"code": {
"example": "blabla",
"type": "string"
},
"file": {
"example": "geosite.dat",
"type": "string"
},
"reason": {
"example": "categoryMissing",
"type": "string"
},
"token": {
"example": "geosite:blabla",
"type": "string"
}
},
"required": [
"reason",
"token"
],
"type": "object"
},
"HistoryOfSeeders": {
"description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
"properties": {
@@ -2272,10 +2108,31 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 20,
"type": "integer"
},
"activeEmails": {
"example": [
"a@example.com"
],
"items": {
"type": "string"
},
"type": "array"
},
"address": {
"example": "node.example.com",
"type": "string"
},
"agentUpdateError": {
"type": "string"
},
"agentUpdateState": {
"description": "Agent one-click update bookkeeping (item 20), agent nodes only:\nthe last intent's state/target and, on failure, its error.",
"example": "failed",
"type": "string"
},
"agentUpdateTarget": {
"example": "A1.0.1",
"type": "string"
},
"allowPrivateAddress": {
"example": false,
"type": "boolean"
@@ -2310,10 +2167,28 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 1,
"type": "integer"
},
"depletedEmails": {
"example": [
"c@example.com"
],
"items": {
"type": "string"
},
"type": "array"
},
"disabledCount": {
"example": 2,
"type": "integer"
},
"disabledEmails": {
"example": [
"b@example.com"
],
"items": {
"type": "string"
},
"type": "array"
},
"enable": {
"example": true,
"type": "boolean"
@@ -2347,6 +2222,15 @@ export const SCHEMAS: Record<string, unknown> = {
},
"type": "array"
},
"ipv4": {
"description": "Ipv4/Ipv6 carry the resolved externally usable host addresses for\nagent nodes (P13 telemetry, public-address resolution on top).\nThe nodes page renders them as plain text on agent rows instead\nof the panel-URL link used for traditional nodes. Empty for\ntraditional nodes and for agents with no known address yet.",
"example": "203.0.113.7",
"type": "string"
},
"ipv6": {
"example": "2001:db8::7",
"type": "string"
},
"lastError": {
"type": "string"
},
@@ -2386,6 +2270,16 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 5,
"type": "integer"
},
"onlineEmails": {
"description": "Per-category client email lists behind the counts above, from the\nsame attribution bucketing, so the nodes page category chips can\npop over the actual usernames. Empty when the category is empty.",
"example": [
"a@example.com"
],
"items": {
"type": "string"
},
"type": "array"
},
"outboundTag": {
"example": "direct",
"type": "string"
@@ -2879,71 +2773,6 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"SubBalancer": {
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
"properties": {
"createdAt": {
"example": 1710000000000,
"format": "int64",
"type": "integer"
},
"enabled": {
"description": "No gorm default:true — a bool default makes an explicit false at insert\ncollapse back to the column default (zero value is skipped).",
"example": true,
"type": "boolean"
},
"id": {
"example": 1,
"type": "integer"
},
"inboundIds": {
"example": [
1,
3
],
"items": {
"type": "integer"
},
"type": "array"
},
"remark": {
"example": "auto-fastest",
"maxLength": 256,
"type": "string"
},
"sortOrder": {
"example": 1,
"minimum": 1,
"type": "integer"
},
"strategy": {
"enum": [
"leastLoad",
"leastPing",
"random",
"roundRobin"
],
"example": "random",
"type": "string"
},
"updatedAt": {
"example": 1710000000000,
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"enabled",
"id",
"inboundIds",
"remark",
"sortOrder",
"strategy",
"updatedAt"
],
"type": "object"
},
"User": {
"description": "User represents a user account in the 3x-ui panel.",
"properties": {
+11 -54
View File
@@ -12,33 +12,16 @@ export type transportBits = number;
export interface AllSetting {
datepicker: string;
expireDiff: number;
ipLimitAllowlist: string;
outboundDownThreshold: number;
pageSize: number;
panelOutbound: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashPath: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
subListen: string;
subPath: string;
@@ -46,7 +29,6 @@ export interface AllSetting {
subProfileUrl: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
@@ -63,6 +45,7 @@ export interface AllSetting {
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trafficStatsSwitches: string;
trustedProxyCIDRs: string;
warpUpdateInterval: number;
webBasePath: string;
@@ -80,33 +63,16 @@ export interface AllSettingView {
hasNordSecret: boolean;
hasTgBotToken: boolean;
hasWarpSecret: boolean;
ipLimitAllowlist: string;
outboundDownThreshold: number;
pageSize: number;
panelOutbound: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashPath: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
subListen: string;
subPath: string;
@@ -114,7 +80,6 @@ export interface AllSettingView {
subProfileUrl: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
@@ -131,6 +96,7 @@ export interface AllSettingView {
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trafficStatsSwitches: string;
trustedProxyCIDRs: string;
warpUpdateInterval: number;
webBasePath: string;
@@ -295,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;
@@ -517,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;
@@ -526,7 +489,9 @@ export interface NodeView {
cpuPct: number;
createdAt: number;
depletedCount: number;
depletedEmails?: string[];
disabledCount: number;
disabledEmails?: string[];
enable: boolean;
guid: string;
hasApiToken: boolean;
@@ -534,6 +499,8 @@ export interface NodeView {
inboundCount: number;
inboundSyncMode: string;
inboundTags: string[];
ipv4?: string;
ipv6?: string;
lastError: string;
lastHeartbeat: number;
latencyMs: number;
@@ -543,6 +510,7 @@ export interface NodeView {
netUp: number;
nodeType?: string;
onlineCount: number;
onlineEmails?: string[];
outboundTag: string;
panelVersion: string;
parentGuid?: string;
@@ -657,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;
+11 -56
View File
@@ -30,33 +30,16 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
export const AllSettingSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
ipLimitAllowlist: 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),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashPath: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
@@ -64,7 +47,6 @@ export const AllSettingSchema = z.object({
subProfileUrl: 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),
@@ -81,6 +63,7 @@ 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(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
@@ -99,33 +82,16 @@ export const AllSettingViewSchema = z.object({
hasNordSecret: z.boolean(),
hasTgBotToken: z.boolean(),
hasWarpSecret: z.boolean(),
ipLimitAllowlist: 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),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashPath: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
@@ -133,7 +99,6 @@ export const AllSettingViewSchema = z.object({
subProfileUrl: 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),
@@ -150,6 +115,7 @@ 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(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
@@ -328,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(),
@@ -561,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(),
@@ -570,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(),
@@ -578,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(),
@@ -587,6 +553,7 @@ export const NodeViewSchema = z.object({
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(),
@@ -708,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(),
+1 -19
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;
}
@@ -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]);
+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;
-3
View File
@@ -9,9 +9,6 @@ const TITLE_KEYS: Record<string, string> = {
'/nodes': 'menu.nodes',
'/hosts': 'menu.hosts',
'/settings': 'menu.settings',
'/xray': 'menu.xray',
'/outbound': 'menu.outbounds',
'/routing': 'menu.routing',
};
export function usePageTitle() {
-266
View File
@@ -1,266 +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,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTrafficRow,
} from '@/schemas/xray';
export type { OutboundTrafficRow };
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
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;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutbounds: unknown[];
subscriptionOutboundTags: string[];
outboundsTraffic: OutboundTrafficRow[];
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: 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 [savedXraySetting, setSavedXraySetting] = useState('');
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 syncingRef = useRef(false);
const xraySettingRef = useRef('');
const savedXraySettingRef = useRef(savedXraySetting);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
useEffect(() => {
xraySettingRef.current = xraySetting;
savedXraySettingRef.current = savedXraySetting;
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;
if (!isDirty) {
const pretty = JSON.stringify(config.xraySetting, null, 2);
setXraySettingState(pretty);
setTemplateSettingsState(config.xraySetting);
setSavedXraySetting(pretty);
}
}
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 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 msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting,
});
return { msg, sentXraySetting };
},
onSuccess: ({ msg, sentXraySetting }) => {
if (!msg?.success) return;
setSavedXraySetting(sentXraySetting);
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;
const saveDisabled = savedXraySetting === xraySetting;
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
return useMemo(
() => ({
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
saveAll,
resetToDefault,
}),
[
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
saveAll,
resetToDefault,
],
);
}
+15 -36
View File
@@ -6,11 +6,9 @@ import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CloseOutlined,
CloudServerOutlined,
ClusterOutlined,
CodeOutlined,
DashboardOutlined,
ExportOutlined,
GithubOutlined,
@@ -24,6 +22,7 @@ import {
MoonOutlined,
PushpinFilled,
PushpinOutlined,
RadarChartOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
@@ -174,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,10 +214,7 @@ export default function AppSidebar() {
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ 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: LOGOUT_KEY, icon: 'logout', title: t('logout') },
],
[t],
@@ -251,40 +245,28 @@ export default function AppSidebar() {
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') }],
[t],
);
}, [t]);
const settingsActive = pathname === '/settings';
const xrayActive = pathname === '/xray';
const selectedKey = settingsActive
? `/settings${hash || '#general'}`
: xrayActive
? `/xray${hash || '#basic'}`
: 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]);
@@ -297,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(
@@ -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);
}
+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)}`;
}
+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 {
@@ -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;
+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,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 -1
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';
@@ -187,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],
);
@@ -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>
)}
</>
)}
</>
+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]);
+3 -51
View File
@@ -1,19 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfigProvider, Form, Input, Layout, Menu, Popover, Space, message } from 'antd';
import {
Button,
ConfigProvider,
Form,
Input,
Layout,
Menu,
Popover,
Space,
Spin,
message,
} from 'antd';
import {
KeyOutlined,
LockOutlined,
MoonFilled,
MoonOutlined,
@@ -27,7 +15,7 @@ import { HttpUtil, LanguageManager } from '@/utils';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
import { LoginFormSchema, type LoginFormValues } from '@/schemas/login';
import './LoginPage.css';
const HEADLINE_INTERVAL_MS = 2000;
@@ -45,12 +33,10 @@ export default function LoginPage() {
setMessageInstance(messageApi);
}, [messageApi]);
const [fetched, setFetched] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
const [headlineIndex, setHeadlineIndex] = useState(0);
const methods = useForm<LoginForm>({
defaultValues: { username: '', password: '', twoFactorCode: '' },
defaultValues: { username: '', password: '' },
});
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
@@ -63,19 +49,6 @@ export default function LoginPage() {
return () => window.clearInterval(timer);
}, [headlineWords.length]);
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable');
if (cancelled) return;
if (msg.success) setTwoFactorEnable(!!msg.obj);
setFetched(true);
})();
return () => {
cancelled = true;
};
}, []);
const onSubmit = useCallback(async (values: LoginForm) => {
setSubmitting(true);
try {
@@ -172,11 +145,6 @@ export default function LoginPage() {
</div>
<div className="login-wrapper">
{!fetched ? (
<div className="login-loading">
<Spin size="large" />
</div>
) : (
<div className="login-card">
<div className="brand">
<span className="brand-name">L-UI</span>
@@ -219,21 +187,6 @@ export default function LoginPage() {
/>
</FormField>
{twoFactorEnable && (
<FormField
name="twoFactorCode"
label={t('twoFactorCode')}
rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
>
<Input
prefix={<KeyOutlined />}
autoComplete="one-time-code"
size="large"
placeholder={t('twoFactorCode')}
/>
</FormField>
)}
<Form.Item className="submit-row">
<Button
type="primary"
@@ -248,7 +201,6 @@ export default function LoginPage() {
</Form>
</FormProvider>
</div>
)}
</div>
</Layout.Content>
</Layout>
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import {
Alert,
Button,
@@ -21,6 +22,7 @@ import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import { currentPanelMasterUrl } from './agentMasterUrl';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';
@@ -69,6 +71,7 @@ export default function NodeFormModal({
onOpenChange,
}: NodeFormModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
const [messageApi, messageContextHolder] = message.useMessage();
@@ -472,6 +475,18 @@ export default function NodeFormModal({
</div>
)}
{mode === 'edit' && isAgent && node?.id ? (
<div style={{ marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.nodes.probe.title')}
</div>
<p style={{ marginBottom: 8 }}>{t('pages.nodes.probe.movedHint')}</p>
<Button onClick={() => navigate('/settings#probe')}>
{t('pages.nodes.probe.goSettings')}
</Button>
</div>
) : null}
{!isAgent && (
<FormField
label={t('pages.nodes.allowPrivateAddress')}
+19 -4
View File
@@ -27,6 +27,7 @@ import {
InfoCircleOutlined,
MoreOutlined,
PlusOutlined,
RadarChartOutlined,
RightOutlined,
SafetyCertificateOutlined,
TeamOutlined,
@@ -48,6 +49,7 @@ interface NodeListProps {
onSelectionChange: (ids: number[]) => void;
onAdd: () => void;
onMtls: () => void;
onProbeTasks: () => void;
onEdit: (node: NodeRecord) => void;
onDelete: (node: NodeRecord) => void;
onProbe: (node: NodeRecord) => void;
@@ -218,6 +220,7 @@ export default function NodeList({
onSelectionChange,
onAdd,
onMtls,
onProbeTasks,
onEdit,
onDelete,
onProbe,
@@ -490,10 +493,15 @@ export default function NodeList({
render: (_value, record) => formatPct(record.memPct),
},
{
title: t('pages.nodes.xrayVersion'),
// Core version (V2.0.1 ②): agent rows run sing-box and show
// its version; traditional rows keep showing the xray one.
title: t('pages.nodes.coreVersion'),
dataIndex: 'xrayVersion',
align: 'center',
render: (_value, record) => record.xrayVersion || '-',
render: (_value, record) =>
record.nodeType === 'agent'
? record.singboxVersion || record.xrayVersion || '-'
: record.xrayVersion || '-',
},
{
title: t('pages.nodes.panelVersion') || 'Panel version',
@@ -618,6 +626,9 @@ export default function NodeList({
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
{t('pages.nodes.mtls.title')}
</Button>
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
{t('pages.nodes.probeTasks.title')}
</Button>
{selectedIds.length > 0 && (
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
@@ -834,8 +845,12 @@ export default function NodeList({
<Tag>{formatPct(statsNode.memPct)}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
<Tag>{statsNode.xrayVersion || '-'}</Tag>
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
<Tag>
{statsNode.nodeType === 'agent'
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
: statsNode.xrayVersion || '-'}
</Tag>
</div>
<div className="stat-row">
<span className="stat-label">
+3
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import {
Alert,
@@ -69,6 +70,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
export default function NodesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
@@ -367,6 +369,7 @@ export default function NodesPage() {
onSelectionChange={setSelectedIds}
onAdd={onAdd}
onMtls={() => setMtlsOpen(true)}
onProbeTasks={() => navigate('/settings#probe')}
onEdit={onEdit}
onDelete={onDelete}
onProbe={onProbe}
@@ -0,0 +1,302 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Col, Input, InputNumber, Row, Select, Switch, message } from 'antd';
import { HttpUtil } from '@/utils';
// Item 7 B3: probe/public-page metadata for an agent node, edited in
// place from the node edit dialog. The section is deliberately outside
// the node form: it loads and saves on its own (dedicated endpoints,
// effective immediately on save — no node save, no restart).
interface ProbeMetaWire {
price: number;
currency: string;
billingCycle: string;
expiresAt: string | null;
trafficLimit: number; // bytes
trafficMode: string;
trafficResetDay: number;
public: boolean;
sort: number;
group: string;
publicRemark: string;
notify: boolean;
countryPin: string;
ipv4Pin: string;
ipv6Pin: string;
// V2.0.1: ledger corrections in GB (either sign) and the counted
// interface pin ('' = automatic selection on the agent).
trafficCalUpGb: number;
trafficCalDownGb: number;
statIface: string;
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const NAMED_CYCLES = [
'once',
'monthly',
'quarterly',
'semiannual',
'yearly',
'biennial',
'triennial',
] as const;
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4 }}>{label}</div>
{children}
</div>
);
}
export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
// Loaded meta, keyed by node id so a stale result for a previously
// edited node is never shown (same discipline as the install block
// in NodeFormModal); visibility derives during render.
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
const [trafficGb, setTrafficGb] = useState(0);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
HttpUtil.get<ProbeMetaWire>(`/panel/api/agent/probe-meta/${nodeId}`, {
silent: true,
}).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setLoaded({ nodeId, meta: msg.obj });
setTrafficGb(msg.obj.trafficLimit / 1e9);
}
});
return () => {
cancelled = true;
};
}, [nodeId]);
const meta = loaded && loaded.nodeId === nodeId ? loaded.meta : null;
if (!meta) return null;
const set = <K extends keyof ProbeMetaWire>(key: K, value: ProbeMetaWire[K]) =>
setLoaded((l) => (l ? { nodeId: l.nodeId, meta: { ...l.meta, [key]: value } } : l));
const cycleLabel = (cycle: string) => {
switch (cycle) {
case 'once':
return t('pages.nodes.probe.cycleOnce');
case 'monthly':
return t('pages.nodes.probe.cycleMonthly');
case 'quarterly':
return t('pages.nodes.probe.cycleQuarterly');
case 'semiannual':
return t('pages.nodes.probe.cycleSemiannual');
case 'yearly':
return t('pages.nodes.probe.cycleYearly');
case 'biennial':
return t('pages.nodes.probe.cycleBiennial');
case 'triennial':
return t('pages.nodes.probe.cycleTriennial');
default:
return cycle;
}
};
const cycleOptions: { value: string; label: string }[] = NAMED_CYCLES.map((cycle) => ({
value: cycle,
label: cycleLabel(cycle),
}));
if (!NAMED_CYCLES.includes(meta.billingCycle as (typeof NAMED_CYCLES)[number])) {
cycleOptions.push({ value: meta.billingCycle, label: meta.billingCycle });
}
async function onSave() {
if (!meta) return;
setSaving(true);
try {
const payload: ProbeMetaWire = {
...meta,
trafficLimit: Math.round(trafficGb * 1e9),
};
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
setLoaded({ nodeId, meta: payload });
messageApi.success(t('pages.nodes.probe.saved'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
}
} finally {
setSaving(false);
}
}
return (
<div style={{ marginBottom: 16 }}>
{messageContextHolder}
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t('pages.nodes.probe.title')}</div>
<p style={{ marginBottom: 12 }}>{t('pages.nodes.probe.hint')}</p>
<Row gutter={16}>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.public')}>
<Switch checked={meta.public} onChange={(v) => set('public', v)} />
</Field>
</Col>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.notify')}>
<Switch checked={meta.notify} onChange={(v) => set('notify', v)} />
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.group')}>
<Input value={meta.group} onChange={(e) => set('group', e.target.value)} />
</Field>
</Col>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.sort')}>
<InputNumber
value={meta.sort}
onChange={(v) => set('sort', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
</Row>
<Field label={t('pages.nodes.probe.publicRemark')}>
<Input value={meta.publicRemark} onChange={(e) => set('publicRemark', e.target.value)} />
</Field>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.price')}>
<InputNumber
value={meta.price}
min={0}
onChange={(v) => set('price', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.currency')}>
<Input value={meta.currency} onChange={(e) => set('currency', e.target.value)} />
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.billingCycle')}>
<Select
value={meta.billingCycle}
options={cycleOptions}
onChange={(v) => set('billingCycle', v)}
style={{ width: '100%' }}
/>
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.expiresAt')}>
<Input
value={meta.expiresAt ?? ''}
placeholder="YYYY-MM-DD"
onChange={(e) => set('expiresAt', e.target.value || null)}
/>
</Field>
</Col>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.trafficResetDay')}>
<InputNumber
value={meta.trafficResetDay}
min={1}
max={31}
onChange={(v) => set('trafficResetDay', v ?? 1)}
style={{ width: '100%' }}
/>
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.trafficLimit')}>
<InputNumber
value={trafficGb}
min={0}
onChange={(v) => setTrafficGb(v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.trafficMode')}>
<Select
value={meta.trafficMode}
options={[
{ value: 'sum', label: t('pages.nodes.probe.modeSum') },
{ value: 'up', label: t('pages.nodes.probe.modeUp') },
{ value: 'down', label: t('pages.nodes.probe.modeDown') },
{ value: 'max', label: t('pages.nodes.probe.modeMax') },
]}
onChange={(v) => set('trafficMode', v)}
style={{ width: '100%' }}
/>
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calUp')}>
<InputNumber
value={meta.trafficCalUpGb}
step={0.1}
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calDown')}>
<InputNumber
value={meta.trafficCalDownGb}
step={0.1}
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.statIface')}>
<Input
value={meta.statIface}
placeholder="eth0"
onChange={(e) => set('statIface', e.target.value)}
/>
</Field>
</Col>
</Row>
<p style={{ marginTop: -4, marginBottom: 12 }}>{t('pages.nodes.probe.calHint')}</p>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.countryPin')}>
<Input value={meta.countryPin} onChange={(e) => set('countryPin', e.target.value)} />
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.ipv4Pin')}>
<Input value={meta.ipv4Pin} onChange={(e) => set('ipv4Pin', e.target.value)} />
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.ipv6Pin')}>
<Input value={meta.ipv6Pin} onChange={(e) => set('ipv6Pin', e.target.value)} />
</Field>
</Col>
</Row>
<Button type="primary" loading={saving} onClick={onSave}>
{t('pages.nodes.probe.save')}
</Button>
</div>
);
}
+74 -120
View File
@@ -1,20 +1,15 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
import { ClockCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import { LanguageManager } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
interface GeneralTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
@@ -25,78 +20,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
const { isMobile } = useMediaQuery();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
// Candidates for the panel egress picker: template outbounds plus
// subscription-derived outbounds, and routing balancers. The panel egress
// is injected as a routing rule, so a balancer tag is a valid target
// (it load-balances the panel's own traffic). The geodata picker, by
// contrast, dials a forced tag and can only use a concrete outbound.
const msg = (await HttpUtil.post('/panel/api/xray/', undefined, {
silent: true,
})) as ApiMsg<string>;
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
// The whole-network traffic accounting switches (settings key
// trafficStatsSwitches, ITEM6 §4.3.1): one JSON string carrying the
// four direction keys the sing-box generator's stats gate reads.
const statsSwitches = useMemo(() => {
try {
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const template = (payload.xraySetting || {}) as Record<string, unknown>;
const tags = new Set<string>();
const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
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);
}
const balancerTags: string[] = [];
const routing = (template.routing || {}) as Record<string, unknown>;
const balancers = Array.isArray(routing.balancers) ? routing.balancers : [];
for (const b of balancers) {
if (!b || typeof b !== 'object') continue;
const tag = (b as Record<string, unknown>).tag;
if (typeof tag === 'string' && tag && !tags.has(tag)) balancerTags.push(tag);
}
setOutboundTagList([...tags]);
setBalancerTagList(balancerTags);
} catch {
setOutboundTagList([]);
setBalancerTagList([]);
}
})();
return () => {
cancelled = true;
const parsed = JSON.parse(allSetting.trafficStatsSwitches || '') as Partial<{
inboundUplink: boolean;
inboundDownlink: boolean;
outboundUplink: boolean;
outboundDownlink: boolean;
}>;
return {
inboundUplink: parsed.inboundUplink !== false,
inboundDownlink: parsed.inboundDownlink !== false,
outboundUplink: parsed.outboundUplink === true,
outboundDownlink: parsed.outboundDownlink === true,
};
}, []);
} catch {
return {
inboundUplink: true,
inboundDownlink: true,
outboundUplink: false,
outboundDownlink: false,
};
}
}, [allSetting.trafficStatsSwitches]);
// Outbound tags and balancer tags share one picker. When balancers exist they
// get their own labeled group so it's clear the selection routes through a
// balancer rather than a single outbound.
const outboundOptions = useMemo<
(
| { label: string; value: string }
| { label: string; options: { label: string; value: string }[] }
)[]
>(() => {
const outOpts = outboundTagList.map((tag) => ({ label: tag, value: tag }));
if (balancerTagList.length === 0) return outOpts;
return [
{ label: t('pages.xray.Outbounds'), options: outOpts },
{
label: t('pages.xray.Balancers'),
options: balancerTagList.map((tag) => ({ label: tag, value: tag })),
},
];
}, [outboundTagList, balancerTagList, t]);
const setStatsSwitch =
(key: 'inboundUplink' | 'inboundDownlink' | 'outboundUplink' | 'outboundDownlink') =>
(v: boolean) =>
updateSetting({ trafficStatsSwitches: JSON.stringify({ ...statsSwitches, [key]: v }) });
function onLangChange(value: string) {
setLang(value);
@@ -207,36 +162,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ipLimitAllowlist')}
description={t('pages.settings.ipLimitAllowlistDesc')}
>
<Input
value={allSetting.ipLimitAllowlist}
placeholder="203.0.113.10,198.51.100.0/24"
onChange={(e) => updateSetting({ ipLimitAllowlist: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelOutbound')}
description={t('pages.settings.panelOutboundDesc')}
>
<Select
style={{ width: '100%' }}
allowClear
showSearch
value={allSetting.panelOutbound || undefined}
placeholder={t('pages.settings.panelOutboundPh')}
options={outboundOptions}
onChange={(v) =>
updateSetting({ panelOutbound: (v as string | undefined) || '' })
}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.pageSize')}
@@ -264,6 +189,50 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficStatsInboundUplink')}
description={t('pages.settings.trafficStatsDesc')}
>
<Switch
checked={statsSwitches.inboundUplink}
onChange={setStatsSwitch('inboundUplink')}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficStatsInboundDownlink')}
description={t('pages.settings.trafficStatsDesc')}
>
<Switch
checked={statsSwitches.inboundDownlink}
onChange={setStatsSwitch('inboundDownlink')}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficStatsOutboundUplink')}
description={t('pages.settings.trafficStatsDesc')}
>
<Switch
checked={statsSwitches.outboundUplink}
onChange={setStatsSwitch('outboundUplink')}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficStatsOutboundDownlink')}
description={t('pages.settings.trafficStatsDesc')}
>
<Switch
checked={statsSwitches.outboundDownlink}
onChange={setStatsSwitch('outboundDownlink')}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.language')}>
<Select
value={lang}
@@ -318,21 +287,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
onChange={(e) => updateSetting({ timeLocation: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.datepicker')}
description={t('pages.settings.datepickerDescription')}
>
<Select
value={allSetting.datepicker || 'gregorian'}
onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
style={{ width: '100%' }}
options={[
{ value: 'gregorian', label: t('pages.settings.calendarGregorian') },
{ value: 'jalalian', label: t('pages.settings.calendarJalalian') },
]}
/>
</SettingListItem>
</>
),
},
@@ -0,0 +1,264 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Col, Input, InputNumber, Row, Select, Space, Switch, Typography, message } from 'antd';
import { HttpUtil } from '@/utils';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import ProbeMetaSection from '../nodes/ProbeMetaSection';
import ProbeTasksPanel from './ProbeTasksPanel';
// V2.0.1 probe batch: the dedicated probe settings page. Everything
// probe-related that used to be scattered — the public-page block
// (public-page tab), the latency task manager (nodes-page modal)
// and the per-node probe metadata (node edit dialog) — is gathered
// here and lives nowhere else. Like the surfaces it replaces, each
// block loads and saves through its own endpoints and takes effect
// immediately (no panel save, no restart).
interface ProbeSettingsWire {
publicPage: boolean;
siteName: string;
site: string;
githubProxy: string;
retentionDays: number;
fastIntervalSec: number;
favicon: string;
touchIcon: string;
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const MAX_ICON_BYTES = 20 * 1024;
function readAsDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
// icon budget (moved unchanged from the public-page tab): small
// files pass through; a large raster is scaled down until it fits;
// a large SVG cannot be scaled and is refused here.
async function fileToIconDataURL(file: File): Promise<string> {
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
throw new Error('tooLarge');
}
const bitmap = await createImageBitmap(file);
try {
for (const size of [256, 180, 128, 96, 64]) {
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) break;
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
}
} finally {
bitmap.close();
}
throw new Error('tooLarge');
}
export default function ProbeSettingsTab() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [settings, setSettings] = useState<ProbeSettingsWire | null>(null);
const [saving, setSaving] = useState(false);
const [metaNodeId, setMetaNodeId] = useState<number | undefined>(undefined);
const faviconRef = useRef<HTMLInputElement>(null);
const touchRef = useRef<HTMLInputElement>(null);
const { nodes } = useNodesQuery();
const agentNodes = nodes.filter((n) => n.nodeType === 'agent');
useEffect(() => {
let cancelled = false;
HttpUtil.get<ProbeSettingsWire>('/panel/api/probe/settings', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) setSettings(msg.obj);
});
return () => {
cancelled = true;
};
}, []);
async function saveSettings() {
if (!settings) return;
setSaving(true);
try {
const msg = await HttpUtil.put('/panel/api/probe/settings', settings, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
} finally {
setSaving(false);
}
}
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
if (!file || !settings) return;
try {
const url = await fileToIconDataURL(file);
setSettings({ ...settings, [which]: url });
} catch {
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
}
}
const set = <K extends keyof ProbeSettingsWire>(key: K, value: ProbeSettingsWire[K]) =>
setSettings((s) => (s ? { ...s, [key]: value } : s));
return (
<div>
{messageContextHolder}
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
{settings ? (
<>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
<Switch
checked={settings.publicPage}
onChange={(v) => set('publicPage', v)}
/>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{t('pages.settings.probeThemes.publicPageHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
<Input
value={settings.siteName}
onChange={(e) => set('siteName', e.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
<Input
value={settings.site}
placeholder="https://"
onChange={(e) => set('site', e.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
<Input
value={settings.githubProxy}
placeholder="https://"
onChange={(e) => set('githubProxy', e.target.value)}
/>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.githubProxyHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.retentionDays')}</div>
<InputNumber
min={1}
max={365}
value={settings.retentionDays}
onChange={(v) => set('retentionDays', v ?? 7)}
style={{ width: '100%' }}
/>
<Typography.Text type="secondary">
{t('pages.settings.probe.retentionHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.fastInterval')}</div>
<InputNumber
min={1}
max={60}
value={settings.fastIntervalSec}
onChange={(v) => set('fastIntervalSec', v ?? 3)}
addonAfter="s"
style={{ width: '100%' }}
/>
<Typography.Text type="secondary">
{t('pages.settings.probe.fastIntervalHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
<Space>
{settings.favicon ? (
<img src={settings.favicon} alt="" style={{ width: 24, height: 24 }} />
) : null}
<Button size="small" onClick={() => faviconRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{settings.favicon ? (
<Button size="small" onClick={() => set('favicon', '')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={faviconRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
<Space>
{settings.touchIcon ? (
<img src={settings.touchIcon} alt="" style={{ width: 24, height: 24 }} />
) : null}
<Button size="small" onClick={() => touchRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{settings.touchIcon ? (
<Button size="small" onClick={() => set('touchIcon', '')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={touchRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.iconHint')}
</Typography.Text>
</div>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={saving} onClick={saveSettings}>
{t('save')}
</Button>
</div>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.nodes.probeTasks.title')}
</Typography.Title>
<ProbeTasksPanel nodes={nodes} />
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probe.metaTitle')}
</Typography.Title>
<Select
placeholder={t('pages.settings.probe.metaNodePlaceholder')}
value={metaNodeId}
onChange={setMetaNodeId}
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
style={{ minWidth: 260, marginBottom: 16 }}
/>
{metaNodeId ? <ProbeMetaSection key={metaNodeId} nodeId={metaNodeId} /> : null}
</div>
);
}
@@ -0,0 +1,431 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Input,
InputNumber,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { HttpUtil } from '@/utils';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Probe (latency) task management — the panel form of what used to
// be the nodes-page modal (moved to the probe settings page in
// V2.0.1; the target set lives in exactly one place now). The
// operator maintains the target set here — grouped by the free-form
// region label, no preset list (user ruling Q4). The surface is
// self-contained: it loads and saves on its own through the
// dedicated endpoints, effective immediately (the master pushes the
// set on each agent's next config poll; nothing restarts).
interface ProbeTaskWire {
id: number;
name: string;
target: string;
interval: number;
autoJoin: boolean;
sort: number;
region: string;
nodeIds: number[];
}
interface TaskDraft {
id: number; // 0 = new task
name: string;
target: string;
interval: number;
autoJoin: boolean;
region: string;
nodeIds: number[];
baseNodeIds: number[] | null; // editor-open snapshot (existing tasks)
}
const TASKS_URL = '/panel/api/agent/probe-tasks';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
function parseTargets(text: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of text.split(/[\n,,]+/)) {
const v = raw.trim();
if (v && !seen.has(v)) {
seen.add(v);
out.push(v);
}
}
return out;
}
export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [tasks, setTasks] = useState<ProbeTaskWire[] | null>(null);
const [draft, setDraft] = useState<TaskDraft | null>(null);
const [saving, setSaving] = useState(false);
// Batch-add state (V2.0.1 ⑤): many targets pasted at once sharing
// every other field.
const [batchText, setBatchText] = useState('');
const [batchName, setBatchName] = useState('');
const [batchInterval, setBatchInterval] = useState(60);
const [batchAutoJoin, setBatchAutoJoin] = useState(false);
const [batchRegion, setBatchRegion] = useState('');
const [batchNodeIds, setBatchNodeIds] = useState<number[]>([]);
const [batchSaving, setBatchSaving] = useState(false);
const agentNodes = useMemo(() => nodes.filter((n) => n.nodeType === 'agent'), [nodes]);
const nodeName = useCallback(
(id: number) => nodes.find((n) => n.id === id)?.name ?? `#${id}`,
[nodes],
);
const load = useCallback(async () => {
const msg = await HttpUtil.get<ProbeTaskWire[]>(TASKS_URL, { silent: true });
if (msg?.success && msg.obj) {
setTasks(msg.obj);
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.loadFailed'));
}
}, [messageApi, t]);
useEffect(() => {
void load();
}, [load]);
// Group by region: region labels ascending, the ungrouped bucket
// last; inside a group the panel order (sort) rules.
const groups = useMemo(() => {
const byRegion = new Map<string, ProbeTaskWire[]>();
for (const task of tasks ?? []) {
const key = task.region ?? '';
const list = byRegion.get(key) ?? [];
list.push(task);
byRegion.set(key, list);
}
return [...byRegion.entries()]
.sort(([a], [b]) => {
if (a === b) return 0;
if (a === '') return 1;
if (b === '') return -1;
return a < b ? -1 : 1;
})
.map(([region, list]) => ({ region, list }));
}, [tasks]);
const startCreate = () =>
setDraft({
id: 0,
name: '',
target: '',
interval: 60,
autoJoin: false,
region: '',
nodeIds: [],
baseNodeIds: null,
});
const startEdit = (task: ProbeTaskWire) =>
setDraft({
id: task.id,
name: task.name,
target: task.target,
interval: task.interval,
autoJoin: task.autoJoin,
region: task.region ?? '',
nodeIds: [...task.nodeIds],
baseNodeIds: [...task.nodeIds],
});
async function onSave() {
if (!draft) return;
setSaving(true);
try {
const payload: Record<string, unknown> = {
id: draft.id,
name: draft.name,
target: draft.target,
interval: draft.interval,
autoJoin: draft.autoJoin,
region: draft.region,
nodeIds: draft.nodeIds,
};
if (draft.baseNodeIds) payload.baseNodeIds = draft.baseNodeIds;
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
messageApi.success(t('pages.nodes.probeTasks.saved'));
setDraft(null);
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
} finally {
setSaving(false);
}
}
async function onBatchAdd() {
const targets = parseTargets(batchText);
if (targets.length === 0) {
messageApi.error(t('pages.nodes.probeTasks.batchEmpty'));
return;
}
setBatchSaving(true);
try {
const msg = await HttpUtil.post<{ ids: number[] }>(
`${TASKS_URL}/batch`,
{
targets,
name: batchName,
interval: batchInterval,
autoJoin: batchAutoJoin,
region: batchRegion,
nodeIds: batchNodeIds,
},
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success) {
messageApi.success(
t('pages.nodes.probeTasks.batchDone', { count: msg.obj?.ids.length ?? targets.length }),
);
setBatchText('');
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
} finally {
setBatchSaving(false);
}
}
async function onDelete(task: ProbeTaskWire) {
const msg = await HttpUtil.delete(`${TASKS_URL}/${task.id}`, { silent: true });
if (msg?.success) {
messageApi.success(t('pages.nodes.probeTasks.deleted'));
if (draft?.id === task.id) setDraft(null);
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
}
const columns: ColumnsType<ProbeTaskWire> = [
{ title: t('pages.nodes.probeTasks.name'), dataIndex: 'name', key: 'name' },
{
title: t('pages.nodes.probeTasks.target'),
dataIndex: 'target',
key: 'target',
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{
title: t('pages.nodes.probeTasks.interval'),
dataIndex: 'interval',
key: 'interval',
width: 90,
render: (v: number) => `${v}s`,
},
{
title: t('pages.nodes.probeTasks.autoJoin'),
dataIndex: 'autoJoin',
key: 'autoJoin',
width: 90,
render: (v: boolean) =>
v ? <Tag color="blue">{t('pages.nodes.probeTasks.autoJoinOn')}</Tag> : null,
},
{
title: t('pages.nodes.probeTasks.nodes'),
dataIndex: 'nodeIds',
key: 'nodeIds',
render: (ids: number[]) => <span title={ids.map(nodeName).join(', ')}>{ids.length}</span>,
},
{
title: t('pages.nodes.probeTasks.actions'),
key: 'actions',
width: 110,
render: (_, task) => (
<Space size="small">
<Button size="small" icon={<EditOutlined />} onClick={() => startEdit(task)}>
{t('pages.nodes.probeTasks.edit')}
</Button>
<Popconfirm
title={t('pages.nodes.probeTasks.deleteConfirm')}
onConfirm={() => void onDelete(task)}
okText={t('pages.nodes.probeTasks.delete')}
cancelText={t('pages.nodes.probeTasks.cancel')}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const setDraftField = <K extends keyof TaskDraft>(key: K, value: TaskDraft[K]) =>
setDraft((d) => (d ? { ...d, [key]: value } : d));
return (
<div>
{messageContextHolder}
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
{t('pages.nodes.probeTasks.intro')}
</Typography.Paragraph>
<div
style={{
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
borderRadius: 8,
padding: 12,
marginBottom: 16,
}}
>
<Typography.Text strong>{t('pages.nodes.probeTasks.batchTitle')}</Typography.Text>
<Input.TextArea
rows={3}
value={batchText}
placeholder={t('pages.nodes.probeTasks.batchPlaceholder')}
onChange={(e) => setBatchText(e.target.value)}
style={{ marginTop: 8, fontFamily: 'monospace' }}
/>
<Space wrap style={{ marginTop: 8 }}>
<Input
placeholder={t('pages.nodes.probeTasks.name')}
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
style={{ width: 180 }}
/>
<Input
placeholder={t('pages.nodes.probeTasks.region')}
value={batchRegion}
onChange={(e) => setBatchRegion(e.target.value)}
style={{ width: 130 }}
/>
<InputNumber
min={5}
max={3600}
value={batchInterval}
onChange={(v) => setBatchInterval(v ?? 60)}
addonAfter="s"
style={{ width: 120 }}
/>
<span>
{t('pages.nodes.probeTasks.autoJoin')}{' '}
<Switch checked={batchAutoJoin} onChange={setBatchAutoJoin} />
</span>
<Select
mode="multiple"
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
value={batchNodeIds}
onChange={setBatchNodeIds}
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
style={{ minWidth: 260 }}
/>
<Button type="primary" loading={batchSaving} onClick={() => void onBatchAdd()}>
{t('pages.nodes.probeTasks.batchSubmit')}
</Button>
</Space>
<div>
<Typography.Text type="secondary">
{t('pages.nodes.probeTasks.batchHint')}
</Typography.Text>
</div>
</div>
{tasks === null ? (
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.loading')}</Typography.Text>
) : tasks.length === 0 && !draft ? (
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.empty')}</Typography.Text>
) : (
groups.map(({ region, list }) => (
<div key={region || '__ungrouped'} style={{ marginBottom: 16 }}>
<Typography.Text strong>
{region || t('pages.nodes.probeTasks.ungrouped')}
</Typography.Text>
<Table<ProbeTaskWire>
size="small"
rowKey="id"
columns={columns}
dataSource={list}
pagination={false}
style={{ marginTop: 6 }}
/>
</div>
))
)}
{draft ? (
<div
style={{
borderTop: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
paddingTop: 12,
marginTop: 4,
}}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space wrap>
<Input
placeholder={t('pages.nodes.probeTasks.name')}
value={draft.name}
onChange={(e) => setDraftField('name', e.target.value)}
style={{ width: 180 }}
/>
<Input
placeholder={t('pages.nodes.probeTasks.region')}
value={draft.region}
onChange={(e) => setDraftField('region', e.target.value)}
style={{ width: 130 }}
/>
<Input
placeholder={t('pages.nodes.probeTasks.targetPlaceholder')}
value={draft.target}
onChange={(e) => setDraftField('target', e.target.value)}
style={{ width: 230, fontFamily: 'monospace' }}
/>
<InputNumber
min={5}
max={3600}
value={draft.interval}
onChange={(v) => setDraftField('interval', v ?? 60)}
addonAfter="s"
style={{ width: 120 }}
/>
</Space>
<Space wrap>
<span>
{t('pages.nodes.probeTasks.autoJoin')}{' '}
<Switch checked={draft.autoJoin} onChange={(v) => setDraftField('autoJoin', v)} />
</span>
<Select
mode="multiple"
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
value={draft.nodeIds}
onChange={(v) => setDraftField('nodeIds', v)}
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
style={{ minWidth: 260 }}
/>
</Space>
<Space>
<Button type="primary" loading={saving} onClick={() => void onSave()}>
{t('pages.nodes.probeTasks.save')}
</Button>
<Button onClick={() => setDraft(null)}>{t('pages.nodes.probeTasks.cancel')}</Button>
</Space>
</Space>
</div>
) : (
<Button icon={<PlusOutlined />} onClick={startCreate} style={{ marginTop: 8 }}>
{t('pages.nodes.probeTasks.add')}
</Button>
)}
</div>
);
}
@@ -0,0 +1,670 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Switch,
Tag,
Typography,
message,
} from 'antd';
import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
// Item 7 B5: the public status page's theme store + notify block.
// (The public-page settings themselves moved to the probe settings
// page in V2.0.1.) Like the probe-meta section this surface is
// self-contained — it loads and saves through its own endpoints and
// every change takes effect immediately (no panel save, no
// restart). Theme packages are static files served by the panel;
// installing one never runs its code here.
interface ThemeConfigField {
key: string;
type: string;
label: string;
default?: unknown;
help?: string;
options?: { value: string; label: string }[];
}
interface ThemeWire {
name: string;
short: string;
description: string;
version: string;
author: string;
url: string;
config?: ThemeConfigField[];
selected: boolean;
builtin: boolean;
preview: boolean;
}
interface ThemesPayload {
themes: ThemeWire[];
}
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
// The webhook URL and headers are secrets: the GET answers only
// whether they are set, and the PUT omits them unless the admin
// edited the field (an explicit clear sends "").
interface NotifySettingsWire {
notifyGrace: number;
notifyTraffic: number;
notifyExpiry: number;
notifyLogin: boolean;
notifyTelegramText: string;
notifyWebhookBody: string;
telegramConfigured: boolean;
webhookConfigured: boolean;
notifyWebhookUrlSet: boolean;
notifyWebhookHeadersSet: boolean;
}
interface NotifyPayload {
settings: NotifySettingsWire;
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const UPLOAD_CHUNK = 1 << 20;
function basePathPrefix(): string {
const bp = window.X_UI_BASE_PATH;
return typeof bp === 'string' && bp !== '/' ? bp.replace(/\/$/, '') : '';
}
export default function PublicPageTab() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [themes, setThemes] = useState<ThemeWire[]>([]);
const [installURL, setInstallURL] = useState('');
const [installing, setInstalling] = useState(false);
const [uploadPct, setUploadPct] = useState<number | null>(null);
const [busyShort, setBusyShort] = useState('');
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
const [webhookUrl, setWebhookUrl] = useState('');
const [webhookHeaders, setWebhookHeaders] = useState('');
const [urlTouched, setUrlTouched] = useState(false);
const [headersTouched, setHeadersTouched] = useState(false);
const [notifySaving, setNotifySaving] = useState(false);
const [notifyTesting, setNotifyTesting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const applyPayload = useCallback((payload: ThemesPayload) => {
setThemes(payload.themes ?? []);
}, []);
const reload = useCallback(async () => {
const msg = await HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true });
if (msg?.success && msg.obj) applyPayload(msg.obj);
}, [applyPayload]);
useEffect(() => {
let cancelled = false;
HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) applyPayload(msg.obj);
});
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setNotify(msg.obj.settings);
}
});
return () => {
cancelled = true;
};
}, [applyPayload]);
async function saveNotify() {
if (!notify) return;
setNotifySaving(true);
try {
const body: Record<string, unknown> = {
notifyGrace: notify.notifyGrace,
notifyTraffic: notify.notifyTraffic,
notifyExpiry: notify.notifyExpiry,
notifyLogin: notify.notifyLogin,
notifyTelegramText: notify.notifyTelegramText,
notifyWebhookBody: notify.notifyWebhookBody,
};
// Secrets ride along only when the field was edited; an
// edited-but-empty field clears the stored value server-side.
if (urlTouched) body.notifyWebhookUrl = webhookUrl;
if (headersTouched) body.notifyWebhookHeaders = webhookHeaders;
const msg = await HttpUtil.put<NotifyPayload>('/panel/api/probe/notify', body, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setWebhookUrl('');
setWebhookHeaders('');
setUrlTouched(false);
setHeadersTouched(false);
messageApi.success(t('pages.settings.probeThemes.saved'));
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setNotifySaving(false);
}
}
async function testNotify() {
setNotifyTesting(true);
try {
const msg = await HttpUtil.post<{ sent: string[] }>('/panel/api/probe/notify/test', undefined, {
silent: true,
});
if (msg?.success) {
messageApi.success(
t('pages.settings.probeNotify.testSent', { channels: (msg.obj?.sent ?? []).join('、') }),
);
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setNotifyTesting(false);
}
}
async function uploadTheme(file: File | undefined) {
if (!file) return;
if (file.size === 0) {
messageApi.error(t('pages.settings.probeThemes.uploadFailed'));
return;
}
setUploadPct(0);
try {
let offset = 0;
while (offset < file.size) {
const slice = file.slice(offset, offset + UPLOAD_CHUNK);
const msg = await HttpUtil.post<{ received?: number; theme?: ThemeWire }>(
'/panel/api/probe/theme-upload',
slice,
{
silent: true,
params: { offset, total: file.size },
headers: { 'Content-Type': 'application/octet-stream' },
},
);
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.uploadFailed'));
return;
}
if (msg.obj?.theme) {
messageApi.success(
t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }),
);
await reload();
return;
}
offset = msg.obj?.received ?? offset + slice.size;
setUploadPct(Math.round((offset / file.size) * 100));
}
} finally {
setUploadPct(null);
if (fileRef.current) fileRef.current.value = '';
}
}
async function installByURL() {
setInstalling(true);
try {
const msg = await HttpUtil.post<{ theme: ThemeWire }>(
'/panel/api/probe/theme-install',
{ url: installURL },
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success && msg.obj?.theme) {
messageApi.success(t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }));
setInstallURL('');
await reload();
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.installFailed'));
}
} finally {
setInstalling(false);
}
}
async function themeAction(short: string, action: 'select' | 'update' | 'delete') {
setBusyShort(short);
try {
if (action === 'delete') {
const msg = await HttpUtil.delete(`/panel/api/probe/themes/${short}`, { silent: true });
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
return;
}
} else {
const msg = await HttpUtil.post<{ updated?: boolean; version?: string }>(
`/panel/api/probe/themes/${short}/${action}`,
undefined,
{ silent: true },
);
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
return;
}
if (action === 'update') {
if (msg.obj?.updated) {
messageApi.success(
t('pages.settings.probeThemes.updatedTo', { version: msg.obj.version }),
);
} else {
messageApi.success(t('pages.settings.probeThemes.alreadyLatest'));
}
}
}
await reload();
} finally {
setBusyShort('');
}
}
return (
<div>
{messageContextHolder}
<Typography.Title level={5}>{t('pages.settings.probeThemes.title')}</Typography.Title>
<Space wrap style={{ marginBottom: 16 }}>
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
{uploadPct === null
? t('pages.settings.probeThemes.uploadInstall')
: `${t('pages.settings.probeThemes.uploadInstall')} ${uploadPct}%`}
</Button>
<input
ref={fileRef}
type="file"
accept=".gz,application/gzip"
hidden
onChange={(e) => uploadTheme(e.target.files?.[0])}
/>
<Input
style={{ width: 340 }}
value={installURL}
placeholder={t('pages.settings.probeThemes.installByUrlPlaceholder')}
onChange={(e) => setInstallURL(e.target.value)}
/>
<Button loading={installing} disabled={!installURL.trim()} onClick={installByURL}>
{t('pages.settings.probeThemes.installByUrl')}
</Button>
</Space>
<Row gutter={[16, 16]}>
{themes.map((theme) => (
<Col key={theme.short} xs={24} sm={12} lg={8}>
<Card
hoverable
cover={
theme.preview ? (
<img
alt={theme.name}
src={`${basePathPrefix()}/panel/api/probe/themes/${theme.short}/preview`}
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
/>
) : undefined
}
actions={[
theme.selected ? (
<Tag key="on" color="green">
{t('pages.settings.probeThemes.inUse')}
</Tag>
) : (
<Button
key="use"
type="link"
loading={busyShort === theme.short}
onClick={() => themeAction(theme.short, 'select')}
>
{t('pages.settings.probeThemes.use')}
</Button>
),
<Button
key="update"
type="link"
loading={busyShort === theme.short}
onClick={() => themeAction(theme.short, 'update')}
>
{t('update')}
</Button>,
...(theme.config && theme.config.length > 0
? [
<ThemeConfigButton
key="config"
theme={theme}
onSaved={() => messageApi.success(t('pages.settings.probeThemes.saved'))}
onError={(m) => messageApi.error(m)}
/>,
]
: []),
...(!theme.builtin
? [
<Popconfirm
key="del"
title={t('pages.settings.probeThemes.deleteConfirm')}
onConfirm={() => themeAction(theme.short, 'delete')}
>
<Button type="link" danger icon={<DeleteOutlined />}>
{t('delete')}
</Button>
</Popconfirm>,
]
: []),
]}
>
<Card.Meta
title={
<Space>
{theme.name}
{theme.builtin ? <Tag>{t('pages.settings.probeThemes.builtin')}</Tag> : null}
</Space>
}
description={
<div>
<div>{theme.description}</div>
<div>
v{theme.version} · {theme.author}
</div>
</div>
}
/>
</Card>
</Col>
))}
</Row>
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeNotify.notifyTitle')}
</Typography.Title>
<Typography.Paragraph type="secondary">
{t('pages.settings.probeNotify.notifyHint')}
</Typography.Paragraph>
{notify ? (
<>
<Row gutter={[16, 12]}>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.grace')}</div>
<InputNumber
min={1}
max={30}
value={notify.notifyGrace}
onChange={onNumber((v) => setNotify({ ...notify, notifyGrace: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.graceHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.trafficPct')}</div>
<InputNumber
min={0}
max={100}
value={notify.notifyTraffic}
onChange={onNumber((v) => setNotify({ ...notify, notifyTraffic: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.trafficHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.expiryDays')}</div>
<InputNumber
min={0}
max={365}
value={notify.notifyExpiry}
onChange={onNumber((v) => setNotify({ ...notify, notifyExpiry: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.expiryHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.loginAlert')}</div>
<Switch
checked={notify.notifyLogin}
onChange={(v) => setNotify({ ...notify, notifyLogin: v })}
/>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.telegramChannel')}
</div>
{notify.telegramConfigured ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
)}
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.telegramHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.webhookChannel')}
</div>
{notify.webhookConfigured ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
)}
</Col>
<Col xs={24}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.telegramText')}</div>
<Input.TextArea
rows={3}
value={notify.notifyTelegramText}
placeholder={'{{title}}\n{{message}}'}
onChange={(e) => setNotify({ ...notify, notifyTelegramText: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookUrl')}</div>
<Input
value={webhookUrl}
placeholder={
notify.notifyWebhookUrlSet ? t('pages.settings.probeNotify.secretSet') : 'https://'
}
onChange={(e) => {
setWebhookUrl(e.target.value);
setUrlTouched(true);
}}
/>
{notify.notifyWebhookUrlSet && !urlTouched ? (
<Button
type="link"
size="small"
onClick={() => {
setWebhookUrl('');
setUrlTouched(true);
}}
>
{t('pages.settings.probeNotify.clearSecret')}
</Button>
) : null}
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.webhookHeaders')}
</div>
<Input.TextArea
rows={2}
value={webhookHeaders}
placeholder={
notify.notifyWebhookHeadersSet
? t('pages.settings.probeNotify.secretSet')
: 'Authorization: Bearer …'
}
onChange={(e) => {
setWebhookHeaders(e.target.value);
setHeadersTouched(true);
}}
/>
{notify.notifyWebhookHeadersSet && !headersTouched ? (
<Button
type="link"
size="small"
onClick={() => {
setWebhookHeaders('');
setHeadersTouched(true);
}}
>
{t('pages.settings.probeNotify.clearSecret')}
</Button>
) : null}
</Col>
<Col xs={24}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookBody')}</div>
<Input.TextArea
rows={3}
value={notify.notifyWebhookBody}
onChange={(e) => setNotify({ ...notify, notifyWebhookBody: e.target.value })}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.templateHint')}
</Typography.Text>
</div>
</Col>
</Row>
<Space style={{ marginTop: 12 }}>
<Button type="primary" loading={notifySaving} onClick={saveNotify}>
{t('save')}
</Button>
<Button loading={notifyTesting} onClick={testNotify}>
{t('pages.settings.probeNotify.testSend')}
</Button>
</Space>
</>
) : null}
</div>
);
}
// ThemeConfigButton edits one theme's saved config. The form is
// drawn from the theme.json config declaration (passed through
// unparsed by the server); values load from and save to the contract
// endpoint the themes themselves use, and only fields changed from
// their declared defaults are stored (the hub's semantics).
function ThemeConfigButton({
theme,
onSaved,
onError,
}: {
theme: ThemeWire;
onSaved: () => void;
onError: (msg: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const fields = theme.config ?? [];
async function load() {
let saved: Record<string, unknown> = {};
try {
const res = await fetch(`/api/themes/${theme.short}/config`, { credentials: 'same-origin' });
if (res.ok) saved = (await res.json()) as Record<string, unknown>;
} catch {
/* an unreadable config edits from the defaults */
}
const merged: Record<string, unknown> = {};
for (const f of fields) merged[f.key] = saved[f.key] ?? f.default;
setValues(merged);
setOpen(true);
}
async function save() {
setSaving(true);
try {
const changed: Record<string, unknown> = {};
for (const f of fields) {
if (JSON.stringify(values[f.key]) !== JSON.stringify(f.default)) {
changed[f.key] = values[f.key];
}
}
const res = await fetch(`/api/themes/${theme.short}/config`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changed),
});
if (res.ok) {
setOpen(false);
onSaved();
} else {
onError((await res.text()) || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setSaving(false);
}
}
return (
<>
<Button type="link" icon={<SettingOutlined />} onClick={load}>
{t('pages.settings.probeThemes.config')}
</Button>
<Modal
open={open}
title={`${theme.name} · ${t('pages.settings.probeThemes.config')}`}
onCancel={() => setOpen(false)}
onOk={save}
confirmLoading={saving}
>
{fields.map((f) => (
<div key={f.key} style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4 }}>{f.label}</div>
{f.type === 'boolean' ? (
<Switch
checked={Boolean(values[f.key])}
onChange={(v) => setValues({ ...values, [f.key]: v })}
/>
) : f.type === 'number' ? (
<InputNumber
value={typeof values[f.key] === 'number' ? (values[f.key] as number) : undefined}
onChange={(v) => setValues({ ...values, [f.key]: v })}
style={{ width: '100%' }}
/>
) : f.type === 'select' && f.options ? (
<Select
value={values[f.key] as string}
options={f.options}
onChange={(v) => setValues({ ...values, [f.key]: v })}
style={{ width: '100%' }}
/>
) : (
<Input
value={typeof values[f.key] === 'string' ? (values[f.key] as string) : ''}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
)}
{f.help ? (
<div>
<Typography.Text type="secondary">{f.help}</Typography.Text>
</div>
) : null}
</div>
))}
</Modal>
</>
);
}
+2 -6
View File
@@ -54,13 +54,11 @@ export default function SecurityTab() {
setUser((prev) => ({ ...prev, [key]: value }));
}
const sendUpdateUser = useCallback(
async (twoFactorCode = '') => {
const sendUpdateUser = useCallback(async () => {
setUpdating(true);
try {
const msg = (await HttpUtil.post('/panel/api/setting/updateUser', {
...user,
twoFactorCode,
})) as ApiMsg;
if (msg?.success) {
await HttpUtil.post('/logout');
@@ -70,9 +68,7 @@ export default function SecurityTab() {
} finally {
setUpdating(false);
}
},
[user],
);
}, [user]);
function onUpdateUserClick() {
sendUpdateUser();
@@ -2,8 +2,3 @@
margin-bottom: 10px;
}
.ldap-no-inbounds {
margin-top: 6px;
color: var(--ant-color-text-tertiary);
font-size: 12px;
}
+7 -28
View File
@@ -26,23 +26,16 @@ import AppSidebar from '@/layouts/AppSidebar';
import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
import TelegramTab from './TelegramTab';
import PublicPageTab from './PublicPageTab';
import ProbeSettingsTab from './ProbeSettingsTab';
import './SettingsPage.css';
interface ApiMsg {
success?: boolean;
}
const tabSlugs = [
'general',
'security',
'telegram',
'subscription',
'subscription-formats',
'subscription-balancers',
];
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'public-page', 'probe'];
function isIp(h: string): boolean {
if (typeof h !== 'string') return false;
@@ -170,19 +163,6 @@ export default function SettingsPage() {
out.push(t('pages.settings.warnDefaultSubPath'));
}
}
if (allSetting.subJsonEnable) {
let p = allSetting.subJsonPath;
if (allSetting.subJsonURI) {
try {
p = new URL(allSetting.subJsonURI).pathname;
} catch {
/* noop */
}
}
if (p === '/json/') {
out.push(t('pages.settings.warnDefaultJsonPath'));
}
}
return out;
}, [allSetting, t]);
@@ -201,10 +181,10 @@ export default function SettingsPage() {
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription':
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats':
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-balancers':
return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'public-page':
return <PublicPageTab />;
case 'probe':
return <ProbeSettingsTab />;
default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
@@ -270,7 +250,6 @@ export default function SettingsPage() {
</Col>
<Col xs={24} sm={14} className="header-info">
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
</Col>
</Row>
</Card>
@@ -1,172 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { formatInboundLabel } from '@/lib/inbounds/label';
import {
SubBalancerFormSchema,
SubBalancerStrategySchema,
type SubBalancer,
type SubBalancerFormValues,
type SubBalancerStrategy,
} from '@/schemas/subBalancer';
// The JSON subscription only builds proxy outbounds for these protocols;
// mtproto has no proxy-outbound case, so it is excluded from balancer members.
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks',
'vless',
'vmess',
'trojan',
'hysteria',
'wireguard',
]);
const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
leastLoad: 'pages.settings.subBalancers.strategyLeastLoad',
leastPing: 'pages.settings.subBalancers.strategyLeastPing',
random: 'pages.settings.subBalancers.strategyRandom',
roundRobin: 'pages.settings.subBalancers.strategyRoundRobin',
};
function initialState(balancer: SubBalancer | null): SubBalancerFormValues {
return {
remark: balancer?.remark ?? '',
strategy: balancer?.strategy ?? 'random',
inboundIds: [...(balancer?.inboundIds ?? [])],
sortOrder: balancer?.sortOrder ?? 1,
enabled: balancer?.enabled ?? true,
};
}
interface SubBalancerFormModalProps {
open: boolean;
balancer: SubBalancer | null;
onClose: () => void;
onConfirm: (values: SubBalancerFormValues) => void;
}
export default function SubBalancerFormModal({
open,
balancer,
onClose,
onConfirm,
}: SubBalancerFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
const isEdit = balancer != null;
useEffect(() => {
if (open) methods.reset(initialState(balancer));
}, [open, balancer, methods]);
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
const { data: inboundOptionsRaw } = useInboundOptions();
const inboundOptions = useMemo(
() =>
(inboundOptionsRaw ?? [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
.map((ib) => ({
label: formatInboundLabel(ib.tag, ib.remark),
value: ib.id,
title: formatInboundLabel(ib.tag, ib.remark),
})),
[inboundOptionsRaw, inboundIds],
);
function onFinish(values: SubBalancerFormValues) {
const parsed = SubBalancerFormSchema.safeParse(values);
if (!parsed.success) {
messageApi.error(
t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'),
);
return;
}
onConfirm(parsed.data);
}
const strategies = SubBalancerStrategySchema.options.map((value) => ({
value,
label: t(STRATEGY_LABEL_KEYS[value]),
}));
return (
<Modal
open={open}
title={
isEdit
? `${t('edit')} ${t('pages.settings.subBalancers.title')}`
: `+ ${t('pages.settings.subBalancers.add')}`
}
okText={isEdit ? t('pages.clients.submitEdit') : t('create')}
cancelText={t('close')}
mask={{ closable: false }}
width="640px"
onOk={methods.handleSubmit(onFinish)}
onCancel={onClose}
>
{messageContextHolder}
<FormProvider {...methods}>
<Form layout="vertical">
<FormField
label={t('pages.settings.subBalancers.remark')}
name="remark"
required
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.remark) }}
>
<Input placeholder={t('pages.settings.subBalancers.remarkPlaceholder')} />
</FormField>
<FormField label={t('pages.settings.subBalancers.strategy')} name="strategy" required>
<Select options={strategies} />
</FormField>
<FormField
label={t('pages.settings.subBalancers.sortOrder')}
name="sortOrder"
required
tooltip={t('pages.settings.subBalancers.sortOrderHelp')}
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.sortOrder) }}
>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</FormField>
<FormField
label={t('pages.settings.subBalancers.inbounds')}
name="inboundIds"
required
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.inboundIds) }}
>
<Select
mode="multiple"
options={inboundOptions}
maxTagCount="responsive"
listHeight={220}
showSearch={{ optionFilterProp: 'label' }}
/>
</FormField>
<SelectAllClearButtons
options={inboundOptions}
value={inboundIds || []}
onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })}
/>
<FormField
label={t('pages.settings.subBalancers.enabled')}
name="enabled"
valueProp="checked"
>
<Switch />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}
@@ -1,57 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import { FinalMaskForm } from '@/lib/xray/forms/transport';
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
interface SubJsonFinalMaskFormProps {
value: string;
onChange: (next: string) => void;
}
function hasValue(v: unknown): boolean {
if (v == null) return false;
if (Array.isArray(v)) return v.some(hasValue);
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
if (typeof v === 'string') return v.length > 0;
return true;
}
function parseFinalMask(raw: string): FinalMaskStreamSettings {
try {
if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
} catch {
return { tcp: [], udp: [] };
}
return { tcp: [], udp: [] };
}
export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) {
const [form] = Form.useForm();
const [initial] = useState(() => parseFinalMask(value));
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
useEffect(() => {
if (finalmask === undefined) return;
const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
if (next !== value) onChangeRef.current(next);
}, [finalmask, value]);
return (
<Form
form={form}
layout="horizontal"
labelCol={{ flex: '160px' }}
wrapperCol={{ flex: 'auto' }}
colon={false}
initialValues={{ finalmask: initial }}
>
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
</Form>
);
}
@@ -1,359 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Input,
InputNumber,
Popconfirm,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Tooltip,
} from 'antd';
import {
DeleteOutlined,
DeploymentUnitOutlined,
EditOutlined,
PlusOutlined,
RadarChartOutlined,
} from '@ant-design/icons';
import { useSubBalancersQuery } from '@/api/queries/useSubBalancersQuery';
import { useSubBalancerMutations } from '@/api/queries/useSubBalancerMutations';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import type { SubBalancer, SubBalancerFormValues } from '@/schemas/subBalancer';
import { PingConfigSchema, type PingConfigObject } from '@/schemas/observatory';
import { DEFAULT_BURST_OBSERVATORY } from '@/pages/xray/balancers/balancer-helpers';
import SubBalancerFormModal from './SubBalancerFormModal';
import { catTabLabel } from './catTabLabel';
import './SubscriptionFormatsTab.css';
const STRATEGY_COLORS: Record<string, string> = {
leastLoad: 'geekblue',
leastPing: 'green',
random: 'orange',
roundRobin: 'purple',
};
// Single source for the burst-observatory ping defaults: the Zod schema and
// DEFAULT_BURST_OBSERVATORY are kept in sync, so the tab just parses through it.
const DEFAULT_PING_CONFIG = PingConfigSchema.parse({ ...DEFAULT_BURST_OBSERVATORY.pingConfig });
function parsePingConfig(raw: string): PingConfigObject {
try {
return PingConfigSchema.parse(raw ? JSON.parse(raw) : {});
} catch {
return DEFAULT_PING_CONFIG;
}
}
interface SubscriptionBalancersTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export default function SubscriptionBalancersTab({
allSetting,
updateSetting,
}: SubscriptionBalancersTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const { balancers, loading, fetched, fetchError, refetch } = useSubBalancersQuery();
const { create, update, remove } = useSubBalancerMutations();
const { data: inboundOptionsRaw } = useInboundOptions();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<SubBalancer | null>(null);
const inboundLabels = useMemo(() => {
const map = new Map<number, string>();
for (const ib of inboundOptionsRaw ?? []) {
map.set(ib.id, formatInboundLabel(ib.tag, ib.remark));
}
return map;
}, [inboundOptionsRaw]);
async function onConfirm(values: SubBalancerFormValues) {
const msg = editing ? await update(editing.id, values) : await create(values);
if (msg?.success) setModalOpen(false);
}
async function toggleEnabled(balancer: SubBalancer) {
await update(balancer.id, {
remark: balancer.remark,
strategy: balancer.strategy,
inboundIds: balancer.inboundIds,
sortOrder: balancer.sortOrder,
enabled: !balancer.enabled,
});
}
const observatoryEnabled = allSetting.subJsonObservatory !== '';
const observatoryObj = useMemo(
() => parsePingConfig(allSetting.subJsonObservatory),
[allSetting.subJsonObservatory],
);
function setObservatoryEnabled(v: boolean) {
updateSetting({ subJsonObservatory: v ? JSON.stringify(DEFAULT_PING_CONFIG) : '' });
}
function setObservatoryField<K extends keyof PingConfigObject>(
key: K,
value: PingConfigObject[K],
) {
const next = { ...observatoryObj, [key]: value };
updateSetting({ subJsonObservatory: JSON.stringify(next) });
}
const columns = [
{
title: t('pages.settings.subBalancers.sortOrder'),
dataIndex: 'sortOrder',
key: 'sortOrder',
width: 80,
align: 'center' as const,
},
{
title: t('pages.settings.subBalancers.remark'),
dataIndex: 'remark',
key: 'remark',
},
{
title: t('pages.settings.subBalancers.strategy'),
dataIndex: 'strategy',
key: 'strategy',
width: 120,
render: (strategy: string) => (
<Tag color={STRATEGY_COLORS[strategy] ?? 'default'}>{strategy}</Tag>
),
},
{
title: t('pages.settings.subBalancers.inbounds'),
key: 'inbounds',
render: (_: unknown, r: SubBalancer) => {
const labels = r.inboundIds.map((id) => inboundLabels.get(id) ?? `#${id}`);
return (
<Tooltip title={labels.join(', ')}>
<span>{t('pages.settings.subBalancers.inboundsCount', { count: labels.length })}</span>
</Tooltip>
);
},
},
{
title: t('pages.settings.subBalancers.enabled'),
dataIndex: 'enabled',
key: 'enabled',
width: 80,
align: 'center' as const,
render: (_: unknown, r: SubBalancer) => (
<Switch size="small" checked={r.enabled} onChange={() => toggleEnabled(r)} />
),
},
{
title: '',
key: 'actions',
width: 96,
render: (_: unknown, r: SubBalancer) => (
<Space>
<Button
aria-label={t('edit')}
size="small"
icon={<EditOutlined />}
title={t('edit')}
onClick={() => {
setEditing(r);
setModalOpen(true);
}}
/>
<Popconfirm
title={t('pages.settings.subBalancers.deleteConfirm')}
okText={t('delete')}
cancelText={t('cancel')}
onConfirm={() => remove(r.id)}
>
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const balancersTab = (
<div>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.settings.subBalancers.desc')}
/>
{fetchError && (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
title={fetchError}
action={
<Button size="small" onClick={() => refetch()}>
{t('refresh')}
</Button>
}
/>
)}
<div style={{ marginBottom: 12 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
>
{t('pages.settings.subBalancers.add')}
</Button>
</div>
<Table
size="small"
dataSource={balancers}
rowKey={(r) => r.id}
pagination={false}
loading={loading && !fetched}
scroll={{ x: true }}
locale={{ emptyText: t('pages.settings.subBalancers.empty') }}
columns={columns}
/>
</div>
);
const observatoryTab = (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.settings.subBalancers.observatory.note')}
/>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.title')}
description={t('pages.settings.subBalancers.observatory.desc')}
>
<Switch checked={observatoryEnabled} onChange={setObservatoryEnabled} />
</SettingListItem>
{observatoryEnabled && (
<div className="format-settings">
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.destination')}
description={t('pages.settings.subBalancers.observatory.destinationDesc')}
>
<Input
value={observatoryObj.destination}
placeholder="https://www.google.com/generate_204"
onChange={(e) => setObservatoryField('destination', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.connectivity')}
description={t('pages.settings.subBalancers.observatory.connectivityDesc')}
>
<Input
value={observatoryObj.connectivity}
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
onChange={(e) => setObservatoryField('connectivity', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.interval')}
description={t('pages.settings.subBalancers.observatory.intervalDesc')}
>
<Input
value={observatoryObj.interval}
placeholder="1m"
onChange={(e) => setObservatoryField('interval', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.timeout')}
description={t('pages.settings.subBalancers.observatory.timeoutDesc')}
>
<Input
value={observatoryObj.timeout}
placeholder="5s"
onChange={(e) => setObservatoryField('timeout', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.sampling')}
description={t('pages.settings.subBalancers.observatory.samplingDesc')}
>
<InputNumber
value={observatoryObj.sampling}
min={1}
style={{ width: '100%' }}
onChange={onNumber((v) => setObservatoryField('sampling', v))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.httpMethod')}
description={t('pages.settings.subBalancers.observatory.httpMethodDesc')}
>
<Select
value={observatoryObj.httpMethod}
style={{ width: '100%' }}
onChange={(v) => setObservatoryField('httpMethod', v)}
options={['HEAD', 'GET'].map((m) => ({ value: m, label: m }))}
/>
</SettingListItem>
</div>
)}
</>
);
return (
<>
<Tabs
defaultActiveKey="balancers"
items={[
{
key: 'balancers',
label: catTabLabel(
<DeploymentUnitOutlined />,
t('pages.settings.subBalancers.tabBalancers'),
isMobile,
),
children: balancersTab,
},
{
key: 'observatory',
label: catTabLabel(
<RadarChartOutlined />,
t('pages.settings.subBalancers.tabObservatory'),
isMobile,
),
children: observatoryTab,
},
]}
/>
<SubBalancerFormModal
open={modalOpen}
balancer={editing}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</>
);
}
@@ -1,29 +0,0 @@
.subscription-format-sections {
display: grid;
gap: 16px;
}
.subscription-format-card-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.subscription-format-card .ant-card-body {
padding: 0;
}
.format-settings {
margin-bottom: 8px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
overflow: hidden;
}
.format-settings-list {
padding-top: 4px;
}
.noise-card {
margin-bottom: 10px;
}
@@ -1,411 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Input, InputNumber, Select, Switch, Tabs } from 'antd';
import {
FileTextOutlined,
NodeIndexOutlined,
PartitionOutlined,
RocketOutlined,
SendOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { GoRegexInput } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
import './SubscriptionFormatsTab.css';
interface SubscriptionFormatsTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
const DEFAULT_MUX = {
enabled: true,
concurrency: 8,
xudpConcurrency: 16,
xudpProxyUDP443: 'reject',
};
const DEFAULT_RULES: { type: string; outboundTag: string; domain?: string[]; ip?: string[] }[] = [
{ type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
{ type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
];
const directIPsOptions = [
{ label: 'Private IP', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'geoip:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'geoip:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
const directDomainsOptions = [
{ label: 'Private DNS', value: 'geosite:private' },
{ label: '🇮🇷 Iran', value: 'geosite:category-ir' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇷🇺 Russia', value: 'geosite:category-ru' },
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
];
function readJson<T>(raw: string, fallback: T): T {
try {
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export default function SubscriptionFormatsTab({
allSetting,
updateSetting,
}: SubscriptionFormatsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const muxEnabled = allSetting.subJsonMux !== '';
const directEnabled = allSetting.subJsonRules !== '';
const muxObj = useMemo(
() =>
muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX,
[allSetting.subJsonMux, muxEnabled],
);
function setMuxEnabled(v: boolean) {
updateSetting({ subJsonMux: v ? JSON.stringify(DEFAULT_MUX) : '' });
}
function setMuxField<K extends keyof typeof DEFAULT_MUX>(key: K, value: (typeof DEFAULT_MUX)[K]) {
const next = { ...muxObj, [key]: value };
updateSetting({ subJsonMux: JSON.stringify(next) });
}
const ruleArray = useMemo(() => {
if (!directEnabled) return null;
return readJson<typeof DEFAULT_RULES | null>(allSetting.subJsonRules, null);
}, [allSetting.subJsonRules, directEnabled]);
const directIPs = useMemo(() => {
if (!ruleArray) return [];
const ipRule = ruleArray.find((r) => r.ip);
return ipRule?.ip ?? [];
}, [ruleArray]);
const directDomains = useMemo(() => {
if (!ruleArray) return [];
const dRule = ruleArray.find((r) => r.domain);
return dRule?.domain ?? [];
}, [ruleArray]);
function setDirectEnabled(v: boolean) {
updateSetting({ subJsonRules: v ? JSON.stringify(DEFAULT_RULES) : '' });
}
function setDirectIPs(value: string[]) {
if (!ruleArray) return;
let rules = [...ruleArray];
if (value.length === 0) {
rules = rules.filter((r) => !r.ip);
} else {
let idx = rules.findIndex((r) => r.ip);
if (idx === -1) {
rules.push({ ...DEFAULT_RULES[1] });
idx = rules.length - 1;
}
rules[idx] = { ...rules[idx], ip: [...value] };
}
updateSetting({ subJsonRules: JSON.stringify(rules) });
}
function setDirectDomains(value: string[]) {
if (!ruleArray) return;
let rules = [...ruleArray];
if (value.length === 0) {
rules = rules.filter((r) => !r.domain);
} else {
let idx = rules.findIndex((r) => r.domain);
if (idx === -1) {
rules.push({ ...DEFAULT_RULES[0] });
idx = rules.length - 1;
}
rules[idx] = { ...rules[idx], domain: [...value] };
}
updateSetting({ subJsonRules: JSON.stringify(rules) });
}
return (
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<div className="subscription-format-sections">
{allSetting.subJsonEnable && (
<Card
size="small"
className="subscription-format-card"
title={
<span className="subscription-format-card-title">
<FileTextOutlined />
{t('pages.settings.subJsonEnableTitle')}
</span>
}
>
<SettingListItem
paddings="small"
title={<>JSON {t('pages.settings.subPath')}</>}
description={t('pages.settings.subPathDesc')}
>
<Input
value={allSetting.subJsonPath}
placeholder="/json/"
onChange={(e) => updateSetting({ subJsonPath: sanitizePath(e.target.value) })}
onBlur={() =>
updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })
}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={<>JSON {t('pages.settings.subURI')}</>}
description={t('pages.settings.subURIDesc')}
>
<Input
value={allSetting.subJsonURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonAlwaysArray')}
description={t('pages.settings.subJsonAlwaysArrayDesc')}
>
<Switch
checked={allSetting.subJsonAlwaysArray}
onChange={(value) => updateSetting({ subJsonAlwaysArray: value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonAutoDetect')}
description={t('pages.settings.subJsonAutoDetectDesc')}
>
<Switch
checked={allSetting.subJsonAutoDetect}
onChange={(v) => updateSetting({ subJsonAutoDetect: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonUserAgentRegex')}
description={t('pages.settings.subJsonUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subJsonUserAgentRegex}
placeholder="(?i)^myclient([ /]|$)"
onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
{allSetting.subClashEnable && (
<Card
size="small"
className="subscription-format-card"
title={
<span className="subscription-format-card-title">
<NodeIndexOutlined />
{t('pages.settings.subClashEnableTitle')}
</span>
}
>
<SettingListItem
paddings="small"
title={<>Clash {t('pages.settings.subPath')}</>}
description={t('pages.settings.subPathDesc')}
>
<Input
value={allSetting.subClashPath}
placeholder="/clash/"
onChange={(e) =>
updateSetting({ subClashPath: sanitizePath(e.target.value) })
}
onBlur={() =>
updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })
}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={<>Clash {t('pages.settings.subURI')}</>}
description={t('pages.settings.subURIDesc')}
>
<Input
value={allSetting.subClashURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subClashURI: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashAutoDetect')}
description={t('pages.settings.subClashAutoDetectDesc')}
>
<Switch
checked={allSetting.subClashAutoDetect}
onChange={(v) => updateSetting({ subClashAutoDetect: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashUserAgentRegex')}
description={t('pages.settings.subClashUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subClashUserAgentRegex}
placeholder="(?i)(clash|mihomo)"
onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
</div>
),
},
{
key: '2',
label: catTabLabel(
<RocketOutlined />,
t('pages.settings.subFormats.finalMask'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subFormats.finalMask')}
description={t('pages.settings.subFormats.finalMaskDesc')}
/>
<SubJsonFinalMaskForm
value={allSetting.subJsonFinalMask}
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
/>
</>
),
},
{
key: '3',
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.mux')}
description={t('pages.settings.muxDesc')}
>
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
</SettingListItem>
{muxEnabled && (
<div className="format-settings">
<SettingListItem
paddings="small"
title={t('pages.settings.subFormats.concurrency')}
>
<InputNumber
value={muxObj.concurrency}
min={-1}
max={1024}
style={{ width: '100%' }}
onChange={onNumber((v) => setMuxField('concurrency', v))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subFormats.xudpConcurrency')}
>
<InputNumber
value={muxObj.xudpConcurrency}
min={-1}
max={1024}
style={{ width: '100%' }}
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subFormats.xudpUdp443')}
>
<Select
value={muxObj.xudpProxyUDP443}
style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpProxyUDP443', v)}
options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</div>
)}
</>
),
},
{
key: '4',
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.direct')}
description={t('pages.settings.directDesc')}
>
<Switch checked={directEnabled} onChange={setDirectEnabled} />
</SettingListItem>
{directEnabled && (
<div className="format-settings">
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
onChange={setDirectIPs}
options={directIPsOptions}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={
<>
{t('pages.settings.direct')} {t('domainName')}
</>
}
>
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
onChange={setDirectDomains}
options={directDomainsOptions}
/>
</SettingListItem>
</div>
)}
</>
),
},
]}
/>
);
}
@@ -1,15 +1,11 @@
import { Alert, Button, Input, InputNumber, Switch, Tabs, Tag } from 'antd';
import { Input, InputNumber, Switch, Tabs } from 'antd';
import {
BranchesOutlined,
CompassOutlined,
IdcardOutlined,
InfoCircleOutlined,
NodeIndexOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
@@ -23,17 +19,11 @@ interface SubscriptionGeneralTabProps {
updateSetting: (patch: Partial<AllSetting>) => void;
}
const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
const remoteSourceBadge = (value: string) =>
isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined;
export default function SubscriptionGeneralTab({
allSetting,
updateSetting,
}: SubscriptionGeneralTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { isMobile } = useMediaQuery();
return (
@@ -55,36 +45,6 @@ export default function SubscriptionGeneralTab({
onChange={(v) => updateSetting({ subEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonEnableTitle')}
description={t('pages.settings.subJsonEnable')}
>
<Switch
checked={allSetting.subJsonEnable}
onChange={(v) => updateSetting({ subJsonEnable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
<Switch
checked={allSetting.subClashEnable}
onChange={(v) => updateSetting({ subClashEnable: v })}
/>
</SettingListItem>
{(allSetting.subJsonEnable || allSetting.subClashEnable) && (
<Alert
type="info"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subFormatsTipTitle')}
description={t('pages.settings.subFormatsTipDesc')}
action={
<Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
{t('pages.settings.subFormatsTipAction')}
</Button>
}
/>
)}
<SettingListItem
paddings="small"
title={t('pages.settings.subListen')}
@@ -197,6 +157,16 @@ export default function SubscriptionGeneralTab({
onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subHideSettings')}
description={t('pages.settings.subHideSettingsDesc')}
>
<Switch
checked={allSetting.subHideSettings}
onChange={(v) => updateSetting({ subHideSettings: v })}
/>
</SettingListItem>
</>
),
},
@@ -253,28 +223,6 @@ export default function SubscriptionGeneralTab({
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subThemeDir')}
description={
<>
{t('pages.settings.subThemeDirDesc')}{' '}
<a
href="https://git.881218.xyz/rogue/3x-ui-agent/src/branch/main/docs/custom-subscription-templates.md"
target="_blank"
rel="noopener noreferrer"
>
{t('pages.settings.subThemeDirDocs')}
</a>
</>
}
>
<Input
value={allSetting.subThemeDir}
placeholder="/etc/3x-ui/sub_templates/my-theme/"
onChange={(e) => updateSetting({ subThemeDir: e.target.value })}
/>
</SettingListItem>
</>
),
},
@@ -306,109 +254,6 @@ export default function SubscriptionGeneralTab({
</>
),
},
{
key: '5',
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subEnableRouting')}
description={t('pages.settings.subEnableRoutingDesc')}
>
<Switch
checked={allSetting.subEnableRouting}
onChange={(v) => updateSetting({ subEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subRoutingRules')}
badge={remoteSourceBadge(allSetting.subRoutingRules)}
description={t('pages.settings.subRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subRoutingRules}
placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subHideSettings')}
description={t('pages.settings.subHideSettingsDesc')}
>
<Switch
checked={allSetting.subHideSettings}
onChange={(v) => updateSetting({ subHideSettings: v })}
/>
</SettingListItem>
</>
),
},
{
key: '6',
label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashEnableRouting')}
description={t('pages.settings.subClashEnableRoutingDesc')}
>
<Switch
checked={allSetting.subClashEnableRouting}
onChange={(v) => updateSetting({ subClashEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashRoutingRules')}
badge={remoteSourceBadge(allSetting.subClashRules)}
description={t('pages.settings.subClashRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subClashRules}
rows={8}
placeholder={
'https://.../routing.yaml\n\nor inline rules:\nGEOSITE,category-ir,DIRECT'
}
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '7',
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyEnableRouting')}
description={t('pages.settings.subIncyEnableRoutingDesc')}
>
<Switch
checked={allSetting.subIncyEnableRouting}
onChange={(v) => updateSetting({ subIncyEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyRoutingRules')}
badge={remoteSourceBadge(allSetting.subIncyRoutingRules)}
description={t('pages.settings.subIncyRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subIncyRoutingRules}
placeholder="incy://routing/onadd/... or https://.../DEFAULT.JSON"
onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })}
/>
</SettingListItem>
</>
),
},
]}
/>
);
+1 -131
View File
@@ -17,14 +17,12 @@ import {
Row,
Space,
Tag,
Tooltip,
} from 'antd';
import {
AndroidOutlined,
AppleOutlined,
CopyOutlined,
DownOutlined,
DownloadOutlined,
MoonFilled,
MoonOutlined,
QrcodeOutlined,
@@ -61,8 +59,6 @@ const totalByte = Number(subData.totalByte || 0);
const expireMs = Number(subData.expire || 0) * 1000;
const lastOnlineMs = Number(subData.lastOnline || 0);
const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || '';
const subTitle = subData.subTitle || '';
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
@@ -70,8 +66,6 @@ const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
const datepicker = subData.datepicker || 'gregorian';
const announce = subData.announce || '';
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
const isUnlimited = totalByte <= 0 && expireMs === 0;
const isActive = (() => {
if (!enabled) return false;
@@ -329,7 +323,7 @@ export default function SubPage() {
isActive={isActive}
/>
{(subUrl || subJsonUrl || subClashUrl) && (
{subUrl && (
<>
<Divider>{t('subscription.title')}</Divider>
<div className="links-section">
@@ -385,130 +379,6 @@ export default function SubPage() {
</div>
</div>
)}
{subJsonUrl && (
<div className="sub-link-row">
<Tag color="purple" className="sub-link-tag">
JSON
</Tag>
<a
href={subJsonUrl}
target="_blank"
rel="noopener noreferrer"
className="sub-link-title sub-link-anchor"
title={subJsonUrl}
>
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subJsonUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(subJsonUrl)}
aria-label={t('copy')}
title={t('copy')}
/>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag color="purple" className="qr-tag">
{t('pages.settings.subSettings')} JSON
</Tag>
<QRCode
value={subJsonUrl}
size={QR_SIZE}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
</div>
</div>
)}
{subClashUrl && (
<div className="sub-link-row">
<Tooltip title="Clash / Mihomo">
<Tag color="gold" className="sub-link-tag">
CLASH
</Tag>
</Tooltip>
<a
href={subClashUrl}
target="_blank"
rel="noopener noreferrer"
className="sub-link-title sub-link-anchor"
title={subClashUrl}
>
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subClashUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(subClashUrl)}
aria-label={t('copy')}
title={t('copy')}
/>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag color="gold" className="qr-tag">
Clash / Mihomo
</Tag>
<QRCode
value={subClashUrl}
size={QR_SIZE}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
</div>
</div>
)}
</div>
</>
)}
@@ -1,40 +0,0 @@
import { useTranslation } from 'react-i18next';
import type { DeletionImpact } from './reference-cleanup';
interface DeletionImpactListProps {
impact: DeletionImpact;
}
export default function DeletionImpactList({ impact }: DeletionImpactListProps) {
const { t } = useTranslation();
const lines: string[] = [];
for (const rule of impact.rules) {
lines.push(
rule.fate === 'removed'
? t('pages.xray.refCleanup.ruleRemoved', { label: rule.label })
: t('pages.xray.refCleanup.ruleModified', { label: rule.label, keeps: rule.keeps ?? '' }),
);
}
for (const balancer of impact.balancers) {
lines.push(t('pages.xray.refCleanup.balancerRemoved', { tag: balancer.tag }));
}
if (impact.observatory) lines.push(t('pages.xray.observatory.deleteAlsoObservatory'));
if (impact.burst) lines.push(t('pages.xray.observatory.deleteAlsoBurst'));
if (lines.length === 0) return null;
return (
<div>
<p style={{ marginBottom: 8 }}>{t('pages.xray.refCleanup.header')}</p>
<ul style={{ margin: 0, paddingInlineStart: 20 }}>
{lines.map((line, i) => (
<li key={i}>
<bdi>{line}</bdi>
</li>
))}
</ul>
</div>
);
}
-8
View File
@@ -1,8 +0,0 @@
.xray-page .advanced-meta h4 {
margin: 0 0 4px;
}
.xray-page .advanced-meta p {
margin: 0;
opacity: 0.7;
}
-286
View File
@@ -1,286 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import {
Alert,
Button,
Card,
Col,
ConfigProvider,
FloatButton,
Layout,
message,
Result,
Row,
Space,
Spin,
} from 'antd';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/layouts/AppSidebar';
import { setMessageInstance } from '@/utils/messageBus';
import { BasicsTab } from './basics';
import { propagateOutboundTagRename } from './basics/helpers';
import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds';
import {
cleanupOrphanedBalancerLoopbacks,
ensureMissingBalancerLoopbacks,
detectBalancerCycles,
} from './balancers/balancer-loopback';
import { WarpModal, NordModal, PiaModal } from './overrides';
import './XrayPage.css';
const SECTION_SLUGS = ['basic', 'routing', 'outbound'];
export default function XrayPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const xs = useXraySetting();
const {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
fetchAll,
resetOutboundsTraffic,
saveAll,
resetToDefault,
} = xs;
const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false);
const [piaOpen, setPiaOpen] = useState(false);
const location = useLocation();
const pathSection =
location.pathname === '/outbound'
? 'outbound'
: location.pathname === '/routing'
? 'routing'
: '';
const sectionSlug = pathSection || location.hash.replace(/^#/, '');
const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function onAddOutbound(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
tt.outbounds.push(outbound as never);
});
}
function onResetOutbound(payload: {
index: number;
outbound: Record<string, unknown>;
oldTag?: string;
newTag?: string;
}) {
mutate((tt) => {
if (!tt.outbounds || payload.index < 0) return;
tt.outbounds[payload.index] = payload.outbound as never;
if (payload.oldTag && payload.newTag) {
propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
}
});
}
function onRemoveOutboundByTag(tag: string) {
mutate((tt) => {
if (!tt.outbounds) return;
const idx = tt.outbounds.findIndex((o) => o?.tag === tag);
if (idx >= 0) tt.outbounds.splice(idx, 1);
});
}
function onRemoveOutboundByIndex(index: number) {
mutate((tt) => {
if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
});
}
function onRemoveRoutingRules(payload: { prefix: string }) {
mutate((tt) => {
const rules = tt.routing?.rules;
if (!Array.isArray(rules)) return;
tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
});
}
function onSaveAll() {
try {
JSON.parse(xraySetting);
} catch (e) {
messageApi.error(`Template JSON: ${(e as Error).message}`);
return;
}
if (templateSettings) {
const clone = JSON.parse(JSON.stringify(templateSettings));
ensureMissingBalancerLoopbacks(clone);
cleanupOrphanedBalancerLoopbacks(clone);
const cycles = detectBalancerCycles(clone);
if (cycles.length > 0) {
const names = cycles.map((c) => c.join(' → ')).join(', ');
messageApi.error(t('pages.xray.balancer.balancerFallbackCycle') + ' (' + names + ')');
return;
}
const serialized = JSON.stringify(clone, null, 2);
setXraySetting(serialized);
setTemplateSettings(clone);
}
saveAll();
}
const scrollTarget = () => document.getElementById('content-layout') || window;
const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
const sectionBody = (() => {
switch (activeSection) {
case 'routing':
return (
<RoutingTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
inboundTags={inboundTags}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
case 'outbound':
return (
<OutboundsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic}
inboundTags={inboundTags}
subscriptionOutbounds={subscriptionOutbounds}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
onShowPia={() => setPiaOpen(true)}
onRefreshXrayData={fetchAll}
/>
);
default:
return (
<BasicsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
onResetDefault={resetToDefault}
/>
);
}
})();
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
spinning={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" onClick={fetchAll}>
{t('check')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
<Col span={24}>
<Card hoverable>
<Row className="header-row">
<Col xs={24} sm={14} className="header-actions">
<Space>
<Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
{t('pages.xray.save')}
</Button>
</Space>
</Col>
<Col xs={24} sm={10} className="header-info">
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card hoverable>{sectionBody}</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<WarpModal
open={warpOpen}
templateSettings={templateSettings}
onClose={() => setWarpOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={onRemoveOutboundByTag}
/>
<NordModal
open={nordOpen}
templateSettings={templateSettings}
onClose={() => setNordOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={onRemoveOutboundByIndex}
onRemoveRoutingRules={onRemoveRoutingRules}
/>
<PiaModal
open={piaOpen}
templateSettings={templateSettings}
onClose={() => setPiaOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
/>
</Layout>
</ConfigProvider>
);
}
@@ -1,229 +0,0 @@
import type { XraySettingsValue } from '@/hooks/useXraySetting';
const LOOPBACK_PREFIX = '_bl_';
export function isBalancerLoopbackTag(tag: string): boolean {
return tag.startsWith(LOOPBACK_PREFIX);
}
export function loopbackTagFor(targetBalancerTag: string): string {
return LOOPBACK_PREFIX + targetBalancerTag;
}
export function balancerTagFromLoopback(loopbackTag: string): string | null {
if (!isBalancerLoopbackTag(loopbackTag)) return null;
return loopbackTag.slice(LOOPBACK_PREFIX.length);
}
function loopbackMatchesTarget(loopbackTag: string, targetTag: string): boolean {
if (!isBalancerLoopbackTag(loopbackTag)) return false;
const target = balancerTagFromLoopback(loopbackTag);
return target === targetTag;
}
function findLoopbackTarget(settings: XraySettingsValue, loopbackTag: string): string | null {
const rules = (settings.routing?.rules || []) as Array<{
inboundTag?: string[];
balancerTag?: string;
}>;
for (const r of rules) {
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(loopbackTag) && r.balancerTag) {
return r.balancerTag;
}
}
return null;
}
export function resolveLoopbackFallback(settings: XraySettingsValue, fallbackTag: string): string {
if (!fallbackTag || !isBalancerLoopbackTag(fallbackTag)) return fallbackTag;
const target = findLoopbackTarget(settings, fallbackTag);
if (target) return target;
const targetTag = balancerTagFromLoopback(fallbackTag);
return targetTag || fallbackTag;
}
function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): number {
let count = 0;
for (const b of (settings.routing?.balancers || []) as Array<{ fallbackTag?: string }>) {
if (
b.fallbackTag &&
isBalancerLoopbackTag(b.fallbackTag) &&
loopbackMatchesTarget(b.fallbackTag, targetTag)
) {
count++;
}
}
return count;
}
function isGeneralRoutingRule(rule: Record<string, unknown>): boolean {
const inboundTag = rule.inboundTag;
return !Array.isArray(inboundTag) || inboundTag.length === 0;
}
function firstGeneralRoutingRuleIndex(rules: Array<Record<string, unknown>>): number {
for (let i = 0; i < rules.length; i += 1) {
if (isGeneralRoutingRule(rules[i])) return i;
}
return rules.length;
}
export function ensureBalancerLoopback(
settings: XraySettingsValue,
targetBalancerTag: string,
): void {
const lbTag = loopbackTagFor(targetBalancerTag);
if (!Array.isArray(settings.outbounds)) settings.outbounds = [];
const existingIdx = (settings.outbounds as Array<{ tag?: string; protocol?: string }>).findIndex(
(o) => o.tag === lbTag,
);
const newOutbound = { tag: lbTag, protocol: 'loopback', settings: { inboundTag: lbTag } };
if (existingIdx >= 0) {
(settings.outbounds as Record<string, unknown>[])[existingIdx] = newOutbound;
} else {
(settings.outbounds as Record<string, unknown>[]).push(newOutbound);
}
if (!settings.routing) settings.routing = { rules: [], balancers: [] };
if (!Array.isArray(settings.routing.rules)) settings.routing.rules = [];
const rules = settings.routing.rules as Array<Record<string, unknown>>;
const existingRuleIdx = rules.findIndex(
(r) => Array.isArray(r.inboundTag) && (r.inboundTag as string[]).includes(lbTag),
);
if (existingRuleIdx >= 0) {
const existing = rules[existingRuleIdx];
existing.balancerTag = targetBalancerTag;
rules.splice(existingRuleIdx, 1);
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, existing);
} else {
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, {
type: 'field',
inboundTag: [lbTag],
balancerTag: targetBalancerTag,
});
}
}
export function removeBalancerLoopbackIfOrphaned(
settings: XraySettingsValue,
targetBalancerTag: string,
): void {
if (countLoopbackRefs(settings, targetBalancerTag) > 0) return;
removeBalancerLoopback(settings, targetBalancerTag);
}
export function removeBalancerLoopback(
settings: XraySettingsValue,
targetBalancerTag: string,
): void {
const lbTag = loopbackTagFor(targetBalancerTag);
if (Array.isArray(settings.outbounds)) {
settings.outbounds = (settings.outbounds as Array<{ tag?: string }>).filter(
(o) => o.tag !== lbTag,
) as XraySettingsValue['outbounds'];
}
if (settings.routing && Array.isArray(settings.routing.rules)) {
settings.routing.rules = settings.routing.rules.filter(
(r) => !(Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag)),
);
}
}
export function propagateBalancerTagRename(
settings: XraySettingsValue,
oldTag: string,
newTag: string,
): void {
const oldLbTag = loopbackTagFor(oldTag);
const newLbTag = loopbackTagFor(newTag);
if (Array.isArray(settings.outbounds)) {
for (const o of settings.outbounds as Array<{
tag?: string;
settings?: { inboundTag?: string };
}>) {
if (o.tag === oldLbTag) o.tag = newLbTag;
if (o.settings?.inboundTag === oldLbTag) o.settings.inboundTag = newLbTag;
}
}
if (settings.routing && Array.isArray(settings.routing.rules)) {
for (const r of settings.routing.rules as Array<{ inboundTag?: string[] }>) {
if (Array.isArray(r.inboundTag)) {
const idx = r.inboundTag.indexOf(oldLbTag);
if (idx !== -1) r.inboundTag[idx] = newLbTag;
}
}
}
if (settings.routing && Array.isArray(settings.routing.balancers)) {
for (const b of settings.routing.balancers as Array<{ tag?: string; fallbackTag?: string }>) {
if (b.fallbackTag === oldLbTag) b.fallbackTag = newLbTag;
}
}
}
export function detectBalancerCycles(settings: XraySettingsValue): string[][] {
const balancers = (settings.routing?.balancers || []) as Array<{
tag?: string;
fallbackTag?: string;
}>;
const cycles: string[][] = [];
for (const b of balancers) {
if (!b.tag || !b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
const targetTag = balancerTagFromLoopback(b.fallbackTag);
if (!targetTag) continue;
const visited = new Set<string>();
let cursor = targetTag;
while (cursor && !visited.has(cursor)) {
if (cursor === b.tag) {
cycles.push([b.tag, targetTag]);
break;
}
visited.add(cursor);
const next = balancers.find((x) => x.tag === cursor);
const fb = next?.fallbackTag;
if (!fb || !isBalancerLoopbackTag(fb)) break;
cursor = balancerTagFromLoopback(fb) || '';
}
}
return cycles;
}
export function ensureMissingBalancerLoopbacks(settings: XraySettingsValue): void {
const balancers = (settings.routing?.balancers || []) as Array<{
tag?: string;
fallbackTag?: string;
}>;
for (const b of balancers) {
if (!b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
const targetTag = balancerTagFromLoopback(b.fallbackTag);
if (!targetTag) continue;
ensureBalancerLoopback(settings, targetTag);
}
}
export function cleanupOrphanedBalancerLoopbacks(settings: XraySettingsValue): void {
if (!Array.isArray(settings.outbounds)) return;
const orphanedTags: string[] = [];
for (const o of settings.outbounds as Array<{ tag?: string; protocol?: string }>) {
if (o.protocol !== 'loopback' || !o.tag || !isBalancerLoopbackTag(o.tag)) continue;
const targetTag = balancerTagFromLoopback(o.tag);
if (targetTag && countLoopbackRefs(settings, targetTag) === 0) {
orphanedTags.push(targetTag);
}
}
for (const tag of orphanedTags) {
removeBalancerLoopback(settings, tag);
}
}
@@ -1,3 +0,0 @@
.hint-alert {
text-align: center;
}
@@ -1,499 +0,0 @@
import { useCallback } from 'react';
import { onNumber } from '@/utils/onNumber';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
import {
BarChartOutlined,
ClockCircleOutlined,
FileTextOutlined,
ReloadOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './BasicsTab.css';
import {
ACCESS_LOG,
ERROR_LOG,
LOG_LEVELS,
MASK_ADDRESS,
ROUTING_DOMAIN_STRATEGIES,
} from './constants';
interface BasicsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
onResetDefault: () => void;
}
export default function BasicsTab({
templateSettings,
setTemplateSettings,
onResetDefault,
}: BasicsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
const setLevel0 = useCallback(
(field: string, value: number | null) =>
mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.levels) tt.policy.levels = {};
if (!tt.policy.levels['0']) tt.policy.levels['0'] = {};
if (value === null || value === undefined) {
delete tt.policy.levels['0'][field];
} else {
tt.policy.levels['0'][field] = value;
}
}),
[mutate],
);
const metricsCfg = (templateSettings as { metrics?: { tag?: string; listen?: string } } | null)
?.metrics;
const setMetrics = useCallback(
(field: 'tag' | 'listen', value: string) =>
mutate((tt) => {
const node = tt as {
metrics?: { tag?: string; listen?: string };
stats?: Record<string, unknown>;
};
const m: { tag?: string; listen?: string } = { ...(node.metrics ?? {}) };
if (value.trim() === '') {
delete m[field];
} else {
m[field] = value.trim();
}
if (!m.listen && !m.tag) {
delete node.metrics;
} else {
node.metrics = m;
// xray-core's metrics handler needs a stats object to populate.
if (!node.stats) node.stats = {};
}
}),
[mutate],
);
function confirmResetDefault() {
modal.confirm({
title: t('pages.settings.resetDefaultConfig'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => onResetDefault(),
});
}
const freedomStrategy =
(
templateSettings?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct')
?.settings as { domainStrategy?: string } | undefined
)?.domainStrategy ?? 'AsIs';
const directFreedomOutbound = templateSettings?.outbounds?.find(
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
);
const directHappyEyeballs = (() => {
const sockopt = (
directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
)?.sockopt;
const raw = sockopt?.happyEyeballs;
if (raw == null || typeof raw !== 'object') return null;
const parsed = HappyEyeballsSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
})();
const setDirectHappyEyeballs = useCallback(
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
if (idx < 0) {
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
idx = tt.outbounds.length - 1;
}
const ob = tt.outbounds[idx];
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
if (next == null) {
delete sockopt.happyEyeballs;
} else {
sockopt.happyEyeballs = next;
}
if (Object.keys(sockopt).length === 0) {
delete stream.sockopt;
} else {
stream.sockopt = sockopt;
}
if (Object.keys(stream).length === 0) {
delete ob.streamSettings;
} else {
ob.streamSettings = stream;
}
});
},
[mutate],
);
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
const log = (templateSettings?.log || {}) as Record<string, unknown>;
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
const items = [
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.generalConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.FreedomStrategy')}
description={t('pages.xray.FreedomStrategyDesc')}
paddings="small"
control={
<Select
value={freedomStrategy}
style={{ width: '100%' }}
options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
onChange={(next) =>
mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
const idx = tt.outbounds.findIndex(
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
);
if (idx < 0) {
tt.outbounds.push({
protocol: 'freedom',
tag: 'direct',
settings: { domainStrategy: next },
});
} else {
const ob = tt.outbounds[idx];
ob.settings = (ob.settings || {}) as Record<string, unknown>;
(ob.settings as Record<string, unknown>).domainStrategy = next;
}
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.FreedomHappyEyeballs')}
description={t('pages.xray.FreedomHappyEyeballsDesc')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs != null}
onChange={(checked) => {
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
}}
/>
}
/>
{directHappyEyeballs != null && (
<>
<SettingListItem
title={t('pages.inbounds.form.tryDelayMs')}
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
paddings="small"
control={
<InputNumber
min={0}
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={onNumber((v) =>
setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: v,
}),
)}
/>
}
/>
<SettingListItem
title={t('pages.inbounds.form.prioritizeIPv6')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs.prioritizeIPv6}
onChange={(checked) =>
setDirectHappyEyeballs({
...directHappyEyeballs,
prioritizeIPv6: checked,
})
}
/>
}
/>
</>
)}
<SettingListItem
title={t('pages.xray.RoutingStrategy')}
description={t('pages.xray.RoutingStrategyDesc')}
paddings="small"
control={
<Select
value={routingStrategy}
style={{ width: '100%' }}
options={ROUTING_DOMAIN_STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(next) =>
mutate((tt) => {
if (tt.routing) tt.routing.domainStrategy = next;
})
}
/>
}
/>
</>
),
},
{
key: '2',
label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
children: (
<>
{[
['statsInboundUplink', t('pages.xray.statsInboundUplink')],
['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
].map(([field, label]) => (
<SettingListItem
key={field}
title={label}
paddings="small"
control={
<Switch
checked={!!policy[field]}
onChange={(checked) =>
mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.system) tt.policy.system = {};
tt.policy.system[field] = checked;
})
}
/>
}
/>
))}
<SettingListItem
title={t('pages.xray.metricsListen')}
description={t('pages.xray.metricsListenDesc')}
paddings="small"
control={
<Input
value={metricsCfg?.listen ?? ''}
onChange={(e) => setMetrics('listen', e.target.value)}
placeholder="127.0.0.1:11111"
/>
}
/>
<SettingListItem
title={t('pages.xray.metricsTag')}
paddings="small"
control={
<Input
value={metricsCfg?.tag ?? ''}
onChange={(e) => setMetrics('tag', e.target.value)}
placeholder="metrics_out"
/>
}
/>
</>
),
},
{
key: 'connection',
label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.connectionLimitsDesc')}
/>
<SettingListItem
title={t('pages.xray.connIdle')}
description={t('pages.xray.connIdleDesc')}
paddings="small"
control={
<InputNumber
value={typeof level0.connIdle === 'number' ? level0.connIdle : undefined}
min={0}
style={{ width: '100%' }}
placeholder="300"
suffix={t('pages.xray.seconds')}
onChange={(v) => setLevel0('connIdle', v as number | null)}
/>
}
/>
<SettingListItem
title={t('pages.xray.bufferSize')}
description={t('pages.xray.bufferSizeDesc')}
paddings="small"
control={
<InputNumber
value={typeof level0.bufferSize === 'number' ? level0.bufferSize : undefined}
min={0}
style={{ width: '100%' }}
placeholder={t('pages.xray.bufferSizePlaceholder')}
suffix="KB"
onChange={(v) => setLevel0('bufferSize', v as number | null)}
/>
}
/>
</>
),
},
{
key: '3',
label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.logConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.logLevel')}
description={t('pages.xray.logLevelDesc')}
paddings="small"
control={
<Select
value={(log.loglevel as string) || 'warning'}
style={{ width: '100%' }}
options={LOG_LEVELS.map((s) => ({ value: s, label: s }))}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.loglevel = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.accessLog')}
description={t('pages.xray.accessLogDesc')}
paddings="small"
control={
<Select
value={(log.access as string) || ''}
style={{ width: '100%' }}
options={ACCESS_LOG.map((s) => ({ value: s, label: s }))}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.access = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.errorLog')}
description={t('pages.xray.errorLogDesc')}
paddings="small"
control={
<Select
value={(log.error as string) || ''}
style={{ width: '100%' }}
options={[
{ value: '', label: t('empty') },
...ERROR_LOG.map((s) => ({ value: s, label: s })),
]}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.error = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.maskAddress')}
description={t('pages.xray.maskAddressDesc')}
paddings="small"
control={
<Select
value={(log.maskAddress as string) || ''}
style={{ width: '100%' }}
options={[
{ value: '', label: t('empty') },
...MASK_ADDRESS.map((s) => ({ value: s, label: s })),
]}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.maskAddress = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.dnsLog')}
description={t('pages.xray.dnsLogDesc')}
paddings="small"
control={
<Switch
checked={!!log.dnsLog}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.dnsLog = v;
})
}
/>
}
/>
</>
),
},
{
key: 'reset',
label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
children: (
<Space style={{ padding: '0 20px' }}>
<Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
{t('pages.settings.resetDefaultConfig')}
</Button>
</Space>
),
},
];
return (
<>
{modalContextHolder}
<Tabs defaultActiveKey="1" items={items} />
</>
);
}
@@ -1,68 +0,0 @@
export const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
export const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
export const ACCESS_LOG = ['none', './access.log'];
export const ERROR_LOG = ['none', './error.log'];
export const MASK_ADDRESS = ['quarter', 'half', 'full'];
export const BITTORRENT_PROTOCOLS = ['bittorrent'];
export const IPS_OPTIONS = [
{ label: 'Private IPs', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
export const DOMAINS_OPTIONS = [
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
export const BLOCK_DOMAINS_OPTIONS = [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
{ label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
{ label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
{ label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
{ label: 'Adult +18', value: 'geosite:category-porn' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
export const SERVICES_OPTIONS = [
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
{ label: 'OpenAI', value: 'geosite:openai' },
{ label: 'Spotify', value: 'geosite:spotify' },
{ label: 'Netflix', value: 'geosite:netflix' },
{ label: 'Reddit', value: 'geosite:reddit' },
{ label: 'Speedtest', value: 'geosite:speedtest' },
];
export const directSettings = { tag: 'direct', protocol: 'freedom' };
export const blockedSettings = { tag: 'blocked', protocol: 'blackhole', settings: {} };
export const ipv4Settings = {
tag: 'IPv4',
protocol: 'freedom',
settings: { domainStrategy: 'UseIPv4' },
};
-121
View File
@@ -1,121 +0,0 @@
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { blockedSettings, directSettings } from './constants';
export function ruleGetter(
t: XraySettingsValue | null,
outboundTag: string,
property: string,
): string[] {
if (!t?.routing?.rules) return [];
const out: string[] = [];
for (const rule of t.routing.rules) {
if (
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag
) {
const v = (rule as Record<string, unknown>)[property];
if (Array.isArray(v)) out.push(...(v as string[]));
}
}
return out;
}
export function ruleSetter(
t: XraySettingsValue,
outboundTag: string,
property: string,
data: string[],
): void {
if (!t.routing) return;
if (!Array.isArray(t.routing.rules)) t.routing.rules = [];
const current = ruleGetter(t, outboundTag, property);
if (current.length === 0) {
t.routing.rules.push({ type: 'field', outboundTag, [property]: data });
return;
}
const next: typeof t.routing.rules = [];
let inserted = false;
for (const rule of t.routing.rules) {
const matches =
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag;
if (matches) {
if (!inserted && data.length > 0) {
(rule as Record<string, unknown>)[property] = data;
next.push(rule);
inserted = true;
}
} else {
next.push(rule);
}
}
t.routing.rules = next;
}
export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record<string, unknown>) {
if (!t.outbounds || !t.routing) return;
const rules = t.routing.rules || [];
const haveRules = rules.some((r) => r?.outboundTag === tag);
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
if (haveRules && idx < 0) t.outbounds.push(settings as never);
}
export function getDefaultOutboundTag(t: XraySettingsValue | null): string {
const tag = t?.outbounds?.[0]?.tag;
return typeof tag === 'string' && tag.length > 0 ? tag : 'direct';
}
export function setDefaultOutboundTag(t: XraySettingsValue, tag: string): void {
if (!tag) return;
if (!Array.isArray(t.outbounds)) t.outbounds = [];
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
if (idx < 0) {
if (tag === 'direct') t.outbounds.push(directSettings as never);
else if (tag === 'blocked') t.outbounds.push(blockedSettings as never);
else return;
const newIdx = t.outbounds.length - 1;
const [moved] = t.outbounds.splice(newIdx, 1);
t.outbounds.unshift(moved);
} else if (idx > 0) {
const [moved] = t.outbounds.splice(idx, 1);
t.outbounds.unshift(moved);
}
}
export function propagateOutboundTagRename(
t: XraySettingsValue,
oldTag: string,
newTag: string,
): void {
if (!oldTag || !newTag || oldTag === newTag) return;
const rules = t.routing?.rules;
if (Array.isArray(rules)) {
for (const rule of rules) {
if (rule?.outboundTag === oldTag) rule.outboundTag = newTag;
}
}
const balancers = t.routing?.balancers;
if (Array.isArray(balancers)) {
for (const balancer of balancers) {
if (balancer?.fallbackTag === oldTag) balancer.fallbackTag = newTag;
if (Array.isArray(balancer?.selector)) {
balancer.selector = balancer.selector.map((sel) => (sel === oldTag ? newTag : sel));
}
}
}
if (Array.isArray(t.outbounds)) {
for (const outbound of t.outbounds) {
const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
?.streamSettings?.sockopt;
if (sockopt?.dialerProxy === oldTag) sockopt.dialerProxy = newTag;
}
}
}
-1
View File
@@ -1 +0,0 @@
export { default as BasicsTab } from './BasicsTab';
@@ -1,141 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import {
RetweetOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ExportOutlined,
} from '@ant-design/icons';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
interface OutboundCardListProps {
rows: OutboundRow[];
outboundsTraffic: OutboundTrafficRow[];
setFirst: (idx: number) => void;
openEdit: (idx: number) => void;
onResetTraffic: (tag: string) => void;
confirmDelete: (idx: number) => void;
}
export default function OutboundCardList({
rows,
outboundsTraffic,
setFirst,
openEdit,
onResetTraffic,
confirmDelete,
}: OutboundCardListProps) {
const { t } = useTranslation();
if (rows.length === 0) {
return (
<div className="card-empty">
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} aria-hidden="true" />
<div>{t('noData')}</div>
</div>
);
}
return (
<>
{rows.map((record, index) => (
<div key={record.key} className="outbound-card">
<div className="card-head">
<div className="card-identity">
<span className="card-num">{index + 1}</span>
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag}</span>
</Tooltip>
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
record.protocol as never,
) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && (
<Tag color="purple">{record.streamSettings?.security}</Tag>
)}
</>
)}
</div>
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [
{
key: 'top',
label: (
<>
<VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}
</>
),
onClick: () => setFirst(index),
},
]
: []),
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
{
key: 'reset',
label: (
<>
<RetweetOutlined /> {t('pages.inbounds.resetTraffic')}
</>
),
onClick: () => onResetTraffic(record.tag || ''),
},
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
{outboundAddresses(record).length > 0 && (
<div className="address-list">
{outboundAddresses(record).map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))}
</div>
)}
<div className="card-foot">
<span className="traffic-up">
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}
</span>
<span className="traffic-sep" />
<span className="traffic-down">
↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}
</span>
</div>
</div>
))}
</>
);
}
@@ -1,18 +0,0 @@
.item-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 500;
opacity: 0.85;
}
.sniffing-options {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.sniffing-options .ant-checkbox-wrapper {
margin-inline-start: 0;
}
@@ -1,598 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Radio, Select, Space, Tabs, message } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils';
import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms/outbound-form';
import {
canEnableReality,
canEnableStream,
canEnableTls,
canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities';
import {
FLOW_OPTIONS,
HYSTERIA_NETWORK_OPTION,
NETWORK_OPTIONS,
PROTOCOL_OPTIONS,
SERVER_PROTOCOLS,
TARGET_STRATEGY_OPTIONS,
} from './outbound-form-constants';
import {
applyNetworkChange,
buildAddModeValues,
hysteriaStreamSlice,
newStreamSlice,
} from './outbound-form-helpers';
import {
BlackholeFields,
DnsFields,
FreedomFields,
HttpFields,
LoopbackFields,
ServerTarget,
ShadowsocksFields,
SocksFields,
TrojanFields,
VlessFields,
VmessFields,
WireguardFields,
} from './protocols';
import {
GrpcForm,
HttpUpgradeForm,
HysteriaForm,
KcpForm,
MuxForm,
RawForm,
SockoptForm,
WsForm,
XhttpForm,
} from './transport';
import { RealityForm, TlsForm } from './security';
import './OutboundFormModal.css';
type StreamValue = OutboundFormValues['streamSettings'];
interface OutboundFormModalProps {
open: boolean;
outbound: Record<string, unknown> | null;
existingTags: string[];
dialerProxyTags?: string[];
onClose: () => void;
onConfirm: (outbound: Record<string, unknown>) => void;
}
export default function OutboundFormModal({
open,
outbound: outboundProp,
existingTags,
dialerProxyTags,
onClose,
onConfirm,
}: OutboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const methods = useForm<OutboundFormValues>({ defaultValues: buildAddModeValues() });
const [activeKey, setActiveKey] = useState('1');
const [jsonText, setJsonText] = useState('');
const [jsonDirty, setJsonDirty] = useState(false);
const [linkInput, setLinkInput] = useState('');
const isEdit = outboundProp != null;
const title = isEdit
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ??
'') as string;
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ??
'none') as string;
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as
| string
| undefined;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
/*
* Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
* hysteria2:// / wireguard://) and replace form state with the result.
* The current tag is preserved when the parsed link doesn't carry one.
*/
function importLink() {
const link = linkInput.trim();
if (!link) return;
const parsed = parseOutboundLink(link);
if (!parsed) {
messageApi.error('Wrong Link!');
return;
}
const currentTag = methods.getValues('tag');
if (!parsed.tag && currentTag) parsed.tag = currentTag;
const next = rawOutboundToFormValues(parsed);
methods.reset(next);
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
setJsonDirty(false);
setLinkInput('');
messageApi.success('Link imported successfully');
switchTab('1');
}
useEffect(() => {
if (!open) return;
const initial = outboundProp ? rawOutboundToFormValues(outboundProp) : buildAddModeValues();
methods.reset(initial);
setActiveKey('1');
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
setJsonDirty(false);
}, [open, outboundProp, methods]);
useEffect(() => {
if (!streamAllowed) return;
/*
* Wireguard dials its own UDP — only finalmask/sockopt apply, never a
* transport. Don't seed network 'tcp'; clear a leftover one (from a
* protocol switch) so the transmission/security blocks stay hidden.
*/
if (protocol === 'wireguard') {
if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
return;
}
if (network) return;
methods.setValue('streamSettings', {
...newStreamSlice('tcp'),
security: 'none',
} as StreamValue);
}, [streamAllowed, network, protocol, methods]);
useEffect(() => {
if (protocol !== 'hysteria') return;
if (network === 'hysteria' && security === 'tls') return;
const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const slice = hysteriaStreamSlice();
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
methods.setValue('streamSettings', slice as StreamValue);
}, [protocol, network, security, methods]);
useEffect(() => {
if (protocol !== 'wireguard') return;
const sk = (wgSecretKey ?? '').trim();
if (!sk) {
methods.setValue('settings.pubKey', '');
return;
}
try {
const { publicKey } = Wireguard.generateKeypair(sk);
methods.setValue('settings.pubKey', publicKey);
} catch {
methods.setValue('settings.pubKey', '');
}
}, [protocol, wgSecretKey, methods]);
useEffect(() => {
/* eslint-disable-next-line react-hooks/incompatible-library */
const sub = methods.watch((_value, { name, type }) => {
if (name !== 'protocol' || type !== 'change') return;
const nextProtocol = methods.getValues('protocol');
const next = rawOutboundToFormValues({ protocol: nextProtocol });
methods.setValue('settings', next.settings);
if (nextProtocol === 'hysteria') {
methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
} else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
methods.setValue('streamSettings', {
...newStreamSlice('tcp'),
security: 'none',
} as StreamValue);
}
});
return () => sub.unsubscribe();
}, [methods]);
function onSecurityChange(next: string) {
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const cleaned = { ...stream };
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
cleaned.tlsSettings = {
serverName: '',
alpn: [],
fingerprint: '',
echConfigList: '',
verifyPeerCertByName: '',
pinnedPeerCertSha256: '',
};
} else if (next === 'reality') {
cleaned.realitySettings = {
publicKey: '',
fingerprint: 'chrome',
serverName: '',
shortId: '',
spiderX: '',
mldsa65Verify: '',
};
}
cleaned.security = next;
methods.setValue('streamSettings', cleaned as StreamValue);
}
/*
* Network change cascade: swap the per-network sub-key (tcpSettings,
* wsSettings, etc.) so the DU branch matches. Preserve security if
* the new network supports it, otherwise force back to 'none'.
*/
function onNetworkChange(next: string) {
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
}
function onXmuxToggle(checked: boolean) {
if (!checked) return;
const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
}
const duplicateTag = useMemo(() => {
const myTag = tag.trim();
if (!myTag) return false;
if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
return (existingTags || []).includes(myTag);
}, [tag, existingTags, isEdit, outboundProp]);
/*
* Bridge form <-> JSON tab: when leaving the JSON tab back to Basic, push
* any edits into form state. When entering JSON tab, snapshot current
* form values so the user sees the live shape.
*/
function applyJsonToForm(): boolean {
if (!jsonDirty) return true;
const raw = jsonText.trim();
if (!raw) return true;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
const next = rawOutboundToFormValues(parsed);
methods.reset(next);
setJsonDirty(false);
return true;
}
function switchTab(key: string) {
if (typeof document !== 'undefined') {
(document.activeElement as HTMLElement | null)?.blur?.();
}
setActiveKey(key);
}
function onTabChange(key: string) {
if (key === '2') {
const values = methods.getValues();
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
setJsonDirty(false);
switchTab(key);
return;
}
if (key === '1' && activeKey === '2') {
if (!applyJsonToForm()) return;
}
switchTab(key);
}
async function onOk() {
let values: OutboundFormValues;
if (activeKey === '2') {
const raw = jsonText.trim();
if (!raw) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return;
}
values = rawOutboundToFormValues(parsed);
methods.reset(values);
setJsonDirty(false);
} else {
if (!(await methods.trigger())) return;
values = methods.getValues();
}
const tagValue = (values.tag ?? '').trim();
if (!tagValue) {
messageApi.error(t('pages.xray.outboundForm.tagRequired'));
return;
}
if (tagValue.startsWith('_bl_')) {
messageApi.error(t('pages.xray.balancer.reservedPrefix'));
return;
}
const isDuplicateTag =
(existingTags || []).includes(tagValue) &&
!(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
if (isDuplicateTag) {
messageApi.error('Tag already used by another outbound');
return;
}
onConfirm(formValuesToWirePayload(values));
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={780}
onOk={onOk}
onCancel={onClose}
destroyOnHidden
>
<FormProvider {...methods}>
<Form
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
labelWrap
>
<Tabs
activeKey={activeKey}
onChange={onTabChange}
items={[
{
key: '1',
label: t('pages.xray.basicTemplate'),
children: (
<>
<FormField
label={t('protocol')}
name="protocol"
rules={{ validate: rhfZodValidate(OutboundFormBaseSchema.shape.tag) }}
>
<Select id="protocol" options={PROTOCOL_OPTIONS} />
</FormField>
<Controller
control={methods.control}
name="tag"
rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
render={({ field, fieldState }) => {
const errorMessage = fieldState.error?.message
? t(fieldState.error.message, {
defaultValue: fieldState.error.message,
})
: '';
return (
<Form.Item
label={t('pages.xray.outbound.tag')}
required
validateStatus={
errorMessage ? 'error' : duplicateTag ? 'warning' : undefined
}
help={
errorMessage ||
(duplicateTag
? t('pages.xray.outboundForm.tagDuplicate')
: undefined)
}
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
/>
</Form.Item>
);
}}
/>
<FormField label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
</FormField>
<FormField
label={t('pages.xray.outbound.targetStrategy')}
name="targetStrategy"
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
>
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
</FormField>
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
{protocol === 'vmess' && <VmessFields />}
{protocol === 'vless' && <VlessFields />}
{protocol === 'trojan' && <TrojanFields />}
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
{protocol === 'dns' && <DnsFields />}
{protocol === 'freedom' && <FreedomFields />}
{protocol === 'vless' && reverseTag && (
<Controller
control={methods.control}
name="settings.reverseSniffing"
render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
/>
)}
/>
)}
{protocol === 'wireguard' && <WireguardFields />}
{streamAllowed && network && (
<>
<Form.Item label={t('transmission')}>
<Select
value={network}
onChange={onNetworkChange}
options={
protocol === 'hysteria'
? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
{network === 'tcp' && <RawForm />}
{network === 'kcp' && <KcpForm />}
{network === 'ws' && <WsForm />}
{network === 'grpc' && <GrpcForm />}
{network === 'httpupgrade' && <HttpUpgradeForm />}
{network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
{network === 'hysteria' && <HysteriaForm />}
</>
)}
{tlsFlowAllowed && (
<FormField label={t('pages.clients.flow')} name={['settings', 'flow']}>
<Select
allowClear
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
/>
</FormField>
)}
{/* Vision seed knobs only meaningful for the exact
xtls-rprx-vision flow, on TCP+(tls|reality). */}
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
<>
<FormField
label={t('pages.xray.outboundForm.visionTestpre')}
name={['settings', 'testpre']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
<Space.Compact block>
{[0, 1, 2, 3].map((i) => (
<FormField key={i} name={['settings', 'testseed', i]} noStyle>
<InputNumber min={1} style={{ width: '25%' }} />
</FormField>
))}
</Space.Compact>
</Form.Item>
</>
)}
{streamAllowed && network && (
<Form.Item label={t('security')}>
<Radio.Group
value={security}
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
{network !== 'hysteria' && (
<Radio.Button value="none">{t('none')}</Radio.Button>
)}
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
)}
{security === 'tls' && tlsAllowed && <TlsForm />}
{security === 'reality' && realityAllowed && <RealityForm />}
{((streamAllowed && network) ||
!streamAllowed ||
protocol === 'wireguard') && (
<SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
)}
<Controller
control={methods.control}
name="streamSettings.finalmask"
render={({ field }) => (
<FinalMaskField
key={`${protocol}:${network}`}
value={field.value}
onChange={field.onChange}
network={network}
protocol={protocol}
/>
)}
/>
<MuxForm protocol={protocol} network={network} />
</>
),
},
{
key: '2',
label: 'JSON',
children: (
<Space
orientation="vertical"
size={10}
style={{ width: '100%', marginTop: 10 }}
>
<Input.Search
value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
enterButton="Import"
onChange={(e) => setLinkInput(e.target.value)}
onSearch={importLink}
/>
<JsonEditor
value={jsonText}
onChange={(next) => {
setJsonText(next);
setJsonDirty(true);
}}
minHeight="360px"
maxHeight="600px"
/>
</Space>
),
},
]}
/>
</Form>
</FormProvider>
</Modal>
</>
);
}
@@ -1,276 +0,0 @@
.toolbar-right {
display: flex;
justify-content: flex-end;
}
/* Keep this in sync with the other pages' .card-empty (it's a global class):
the previous opacity:0.4 here leaked onto whichever page's empty state was
shown after the Outbounds CSS loaded, fading it. */
.card-empty {
text-align: center;
color: var(--ant-color-text-secondary);
padding: 24px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.outbound-card {
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.card-identity {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.card-num {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.tag-name {
font-weight: 500;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.protocol-line {
display: inline-flex;
flex-wrap: wrap;
gap: 2px;
}
.address-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.address-pill {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--ant-color-fill-tertiary);
}
.warp-cloud-icon {
width: 1em;
height: 1em;
color: #f48120;
flex: 0 0 auto;
}
.warp-cloud-icon svg {
fill: #f48120;
}
.ip-toggle-icon {
cursor: pointer;
opacity: 0.65;
}
.ip-toggle-icon:hover {
opacity: 1;
}
.address-hidden {
filter: blur(5px);
transition: filter 0.2s ease;
}
.address-visible {
filter: none;
}
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.row-index {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.identity-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.card-foot {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.traffic-up {
color: #008771;
font-size: 12px;
}
.traffic-down {
color: #3c89e8;
font-size: 12px;
}
.traffic-sep {
display: inline-block;
width: 4px;
}
.pill-ok,
.pill-fail {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 12px;
font-size: 12px;
}
.pill-ok {
color: #008771;
background: rgba(0, 135, 113, 0.12);
}
.pill-fail {
color: #e04141;
background: rgba(224, 65, 65, 0.12);
}
.empty {
opacity: 0.4;
}
.outbound-test-popover .timing-breakdown {
font-size: 12px;
line-height: 1.6;
min-width: 180px;
max-width: 320px;
}
.outbound-test-popover .td-head {
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.outbound-test-popover .td-head.ok {
color: #008771;
}
.outbound-test-popover .td-head.fail {
color: #e04141;
}
.outbound-test-popover .mode-badge {
font-size: 10px;
font-weight: 500;
padding: 0 6px;
border-radius: 8px;
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
color: var(--ant-color-primary);
margin-left: auto;
}
.outbound-test-popover .endpoint-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
white-space: nowrap;
}
.outbound-test-popover .endpoint-row .ep-addr {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.outbound-test-popover .endpoint-row .ep-meta {
opacity: 0.75;
}
.outbound-test-popover .dot-ok {
color: #008771;
}
.outbound-test-popover .dot-fail {
color: #e04141;
}
.outbound-test-popover .breakdown-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
white-space: nowrap;
}
.outbound-test-popover .breakdown-row .bd-label {
flex: 1;
min-width: 0;
opacity: 0.85;
}
.outbound-test-popover .breakdown-row .bd-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
opacity: 0.75;
}
.subscription-outbounds-head {
margin-bottom: 8px;
}
.subscription-outbounds-title {
font-weight: 600;
margin-bottom: 2px;
}
.subscription-outbounds-desc {
font-size: 12px;
opacity: 0.7;
}
@@ -1,935 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Col,
Dropdown,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Space,
Switch,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import {
PlusOutlined,
CloudOutlined,
ApiOutlined,
MoreOutlined,
RetweetOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CheckCircleOutlined,
WarningOutlined,
ExportOutlined,
ImportOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal';
import OutboundFormModal from './OutboundFormModal';
import { propagateOutboundTagRename } from '../basics/helpers';
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
import DeletionImpactList from '../DeletionImpactList';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import type { XraySettingsValue, SetTemplate, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css';
import type { OutboundRow } from './outbounds-tab-types';
import { originalOutboundIndex } from './outbounds-tab-helpers';
import { useOutboundColumns } from './useOutboundColumns';
import OutboundCardList from './OutboundCardList';
import SubscriptionOutbounds from './SubscriptionOutbounds';
interface OutboundSub {
id: number;
remark?: string;
url?: string;
enabled?: boolean;
allowPrivate?: boolean;
allowInsecure?: boolean;
prepend?: boolean;
priority?: number;
tagPrefix?: string;
updateInterval?: number;
lastUpdated?: number;
lastError?: string;
outboundCount?: number;
}
interface OutboundsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundsTraffic: OutboundTrafficRow[];
inboundTags: string[];
subscriptionOutbounds?: unknown[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
onResetTraffic: (tag: string) => void;
onShowWarp: () => void;
onShowNord: () => void;
onShowPia: () => void;
onRefreshXrayData?: () => void;
}
export default function OutboundsTab({
templateSettings,
setTemplateSettings,
outboundsTraffic,
inboundTags: _inboundTags,
subscriptionOutbounds,
subscriptionOutboundTags,
isMobile,
onResetTraffic,
onShowWarp,
onShowNord,
onShowPia,
onRefreshXrayData,
}: OutboundsTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [modalOpen, setModalOpen] = useState(false);
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [existingTags, setExistingTags] = useState<string[]>([]);
// Subscription manager (CRUD + reorder + refresh + preview)
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
const [subs, setSubs] = useState<OutboundSub[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
const [newSub, setNewSub] = useState({
remark: '',
url: '',
tagPrefix: '',
updateInterval: 600,
enabled: true,
allowPrivate: false,
allowInsecure: false,
prepend: false,
});
const [editingSubId, setEditingSubId] = useState<number | null>(null);
const [savingSub, setSavingSub] = useState(false);
const [refreshingId, setRefreshingId] = useState<number | null>(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [previewing, setPreviewing] = useState(false);
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(
null,
);
// Convenience: expose hours/minutes for the interval input
const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
function setIntervalHM(h: number, m: number) {
const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
setNewSub((prev) => ({ ...prev, updateInterval: secs }));
}
const outbounds = useMemo(
() => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
[templateSettings?.outbounds],
);
const rows = useMemo(
() =>
outbounds
.map((o, i) => ({ ...o, key: i }))
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
[outbounds],
);
const rowsRef = useRef<OutboundRow[]>([]);
rowsRef.current = rows;
const dialerProxyTags = useMemo(() => {
const tags = new Set<string>();
(templateSettings?.outbounds || []).forEach((o, i) => {
if (i === editingIndex) return;
if (o?.protocol === 'blackhole') return;
if (o?.tag) tags.add(o.tag);
});
for (const tag of subscriptionOutboundTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, editingIndex, subscriptionOutboundTags]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function openAdd() {
setEditingOutbound(null);
setEditingIndex(null);
setExistingTags(
(templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg),
);
setModalOpen(true);
}
function openSubManager() {
setSubDrawerOpen(true);
loadSubs();
}
function openEdit(idx: number) {
const target = originalOutboundIndex(rowsRef.current, idx);
setEditingOutbound((templateSettings?.outbounds || [])[target] as Record<string, unknown>);
setEditingIndex(target);
setExistingTags(
(templateSettings?.outbounds || [])
.filter((_, i) => i !== target)
.map((o) => o?.tag)
.filter((tg): tg is string => !!tg),
);
setModalOpen(true);
}
function onConfirm(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
const newTag = typeof outbound.tag === 'string' ? outbound.tag : '';
if (editingIndex == null) {
if (!newTag) return;
tt.outbounds.push(outbound as never);
} else {
const oldTag = tt.outbounds[editingIndex]?.tag;
tt.outbounds[editingIndex] = outbound as never;
if (oldTag && newTag && oldTag !== newTag) {
propagateOutboundTagRename(tt, oldTag, newTag);
}
}
});
setModalOpen(false);
}
function confirmDelete(idx: number) {
const target = originalOutboundIndex(rowsRef.current, idx);
const impact = templateSettings
? planOutboundDeletion(templateSettings, target)
: { rules: [], balancers: [], observatory: false, burst: false };
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
content: <DeletionImpactList impact={impact} />,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => mutate((tt) => applyOutboundDeletion(tt, target)),
});
}
function setFirst(idx: number) {
const target = originalOutboundIndex(rowsRef.current, idx);
mutate((tt) => {
if (!tt.outbounds) return;
const [moved] = tt.outbounds.splice(target, 1);
tt.outbounds.unshift(moved);
});
}
function moveUp(idx: number) {
if (idx <= 0) return;
const target = originalOutboundIndex(rowsRef.current, idx);
const prev = originalOutboundIndex(rowsRef.current, idx - 1);
mutate((tt) => {
if (!tt.outbounds) return;
[tt.outbounds[prev], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[prev]];
});
}
function moveDown(idx: number) {
if (idx >= rowsRef.current.length - 1) return;
const target = originalOutboundIndex(rowsRef.current, idx);
const next = originalOutboundIndex(rowsRef.current, idx + 1);
mutate((tt) => {
if (!tt.outbounds) return;
[tt.outbounds[next], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[next]];
});
}
const [importOpen, setImportOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [exportContent, setExportContent] = useState('');
function exportOutbounds() {
setExportContent(JSON.stringify(outbounds, null, 2));
setExportOpen(true);
}
function importOutbounds(value: string) {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
messageApi.error(t('pages.xray.importInvalidJson'));
return;
}
const obj = parsed as { outbounds?: unknown };
const list = Array.isArray(parsed)
? parsed
: Array.isArray(obj?.outbounds)
? obj.outbounds
: null;
if (!list) {
messageApi.error(t('pages.xray.importInvalidJson'));
return;
}
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
tt.outbounds.push(...(list as never[]));
});
setImportOpen(false);
}
// --- Subscription management (minimal inline UI) ---
async function loadSubs() {
setSubsLoading(true);
try {
const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
} catch {
messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
} finally {
setSubsLoading(false);
}
}
function subBody(src: {
remark?: string;
url?: string;
tagPrefix?: string;
updateInterval?: number;
enabled?: boolean;
allowPrivate?: boolean;
allowInsecure?: boolean;
prepend?: boolean;
}) {
return {
remark: src.remark ?? '',
url: src.url ?? '',
tagPrefix: src.tagPrefix ?? '',
updateInterval: src.updateInterval ?? 600,
enabled: src.enabled ?? true,
allowPrivate: src.allowPrivate ?? false,
allowInsecure: src.allowInsecure ?? false,
prepend: src.prepend ?? false,
};
}
function resetSubForm() {
setNewSub({
remark: '',
url: '',
tagPrefix: '',
updateInterval: 600,
enabled: true,
allowPrivate: false,
allowInsecure: false,
prepend: false,
});
setEditingSubId(null);
setPreviewData(null);
}
function openEditSub(sub: OutboundSub) {
setNewSub({
remark: sub.remark ?? '',
url: sub.url ?? '',
tagPrefix: sub.tagPrefix ?? '',
updateInterval: sub.updateInterval ?? 600,
enabled: sub.enabled ?? true,
allowPrivate: sub.allowPrivate ?? false,
allowInsecure: sub.allowInsecure ?? false,
prepend: sub.prepend ?? false,
});
setEditingSubId(sub.id);
setPreviewData(null);
}
async function saveSub() {
if (!newSub.url.trim()) {
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
return;
}
setSavingSub(true);
try {
const url =
editingSubId != null
? `/panel/api/xray/outbound-subs/${editingSubId}`
: '/panel/api/xray/outbound-subs';
const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
if (r?.success) {
messageApi.success(
t(
editingSubId != null
? 'pages.xray.outboundSub.toastUpdated'
: 'pages.xray.outboundSub.toastAdded',
),
);
const createdId = editingSubId == null ? r.obj?.id : undefined;
resetSubForm();
await loadSubs();
if (createdId) await refreshOne(createdId);
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
} finally {
setSavingSub(false);
}
}
async function previewSub() {
if (!newSub.url.trim()) {
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
return;
}
setPreviewing(true);
setPreviewData(null);
try {
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>(
'/panel/api/xray/outbound-subs/parse',
{ url: newSub.url, allowPrivate: newSub.allowPrivate },
);
if (r?.success && Array.isArray(r.obj)) {
setPreviewData(r.obj);
if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
} finally {
setPreviewing(false);
}
}
async function toggleEnabled(sub: OutboundSub) {
setBusyId(sub.id);
try {
const r = await HttpUtil.post(
`/panel/api/xray/outbound-subs/${sub.id}`,
subBody({ ...sub, enabled: !sub.enabled }),
);
if (r?.success) {
await loadSubs();
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
} finally {
setBusyId(null);
}
}
async function moveSub(id: number, dir: 'up' | 'down') {
setBusyId(id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
if (r?.success) {
await loadSubs();
onRefreshXrayData?.();
}
} catch {
/* ignore */
} finally {
setBusyId(null);
}
}
async function refreshOne(id: number) {
setRefreshingId(id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
if (r?.success) {
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
await loadSubs();
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
} finally {
setRefreshingId(null);
}
}
async function refreshAllSubs() {
if (subs.length === 0) return;
setRefreshingAll(true);
try {
for (const s of subs) {
try {
await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`);
} catch {
/* continue */
}
}
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
await loadSubs();
onRefreshXrayData?.();
} finally {
setRefreshingAll(false);
}
}
async function deleteOne(id: number) {
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
if (r?.success) {
messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
await loadSubs();
onRefreshXrayData?.();
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
}
}
const columns = useOutboundColumns({
rows,
outboundsTraffic,
openEdit,
setFirst,
moveUp,
moveDown,
confirmDelete,
onResetTraffic,
});
return (
<>
{modalContextHolder}
{messageContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[12, 12]} align="middle" justify="space-between">
<Col xs={24} sm={12}>
<Space size="small" wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{!isMobile && t('pages.xray.Outbounds')}
</Button>
<Button icon={<CloudOutlined />} onClick={openSubManager}>
{t('pages.xray.outboundSub.manage')}
</Button>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
{
key: 'pia',
icon: <ApiOutlined />,
label: t('pages.xray.pia.menu'),
onClick: onShowPia,
},
{ type: 'divider' },
{
key: 'import',
icon: <ImportOutlined />,
label: t('pages.xray.importOutbounds'),
onClick: () => setImportOpen(true),
},
{
key: 'export',
icon: <ExportOutlined />,
label: t('pages.xray.exportOutbounds'),
disabled: outbounds.length === 0,
onClick: exportOutbounds,
},
],
}}
>
<Button icon={<MoreOutlined />}>{t('more')}</Button>
</Dropdown>
</Space>
</Col>
<Col xs={24} sm={12} className="toolbar-right">
<Space size="small" wrap>
<Popconfirm
placement="topRight"
okText={t('reset')}
cancelText={t('cancel')}
title={t('pages.inbounds.resetAllTrafficContent')}
onConfirm={() => onResetTraffic('-alltags-')}
>
<Button aria-label={t('pages.inbounds.resetTraffic')} icon={<RetweetOutlined />} />
</Popconfirm>
</Space>
</Col>
</Row>
{isMobile ? (
<OutboundCardList
rows={rows}
outboundsTraffic={outboundsTraffic}
setFirst={setFirst}
openEdit={openEdit}
onResetTraffic={onResetTraffic}
confirmDelete={confirmDelete}
/>
) : (
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
locale={{
emptyText: (
<div className="card-empty">
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
/>
)}
<OutboundFormModal
open={modalOpen}
outbound={editingOutbound}
existingTags={existingTags}
dialerProxyTags={dialerProxyTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
<PromptModal
open={importOpen}
onClose={() => setImportOpen(false)}
title={t('pages.xray.importOutbounds')}
okText={t('pages.xray.importOutbounds')}
type="textarea"
json
onConfirm={importOutbounds}
/>
<TextModal
open={exportOpen}
onClose={() => setExportOpen(false)}
title={t('pages.xray.exportOutbounds')}
content={exportContent}
fileName="outbounds.json"
json
/>
{/* Subscription outbounds (read-only, merged at runtime) */}
{Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
<SubscriptionOutbounds
subscriptionOutbounds={subscriptionOutbounds}
outboundsTraffic={outboundsTraffic}
isMobile={isMobile}
/>
)}
</Space>
<Modal
title={t('pages.xray.outboundSub.title')}
open={subDrawerOpen}
onCancel={() => setSubDrawerOpen(false)}
footer={null}
width={isMobile ? '100%' : 640}
destroyOnHidden
>
<Space orientation="vertical" style={{ width: '100%' }} size="large">
<div>
{editingSubId != null && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color="blue">{t('edit')}</Tag>
<span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
</div>
)}
<Form layout="vertical" size="small">
<Form.Item label={t('pages.xray.outboundSub.remark')}>
<Input
value={newSub.remark}
onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })}
placeholder={t('pages.xray.outboundSub.remarkPlaceholder')}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.url')} required>
<Input
value={newSub.url}
onChange={(e) => setNewSub({ ...newSub, url: e.target.value })}
placeholder={t('pages.xray.outboundSub.urlPlaceholder')}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
<Input
value={newSub.tagPrefix}
onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })}
placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.interval')}>
<Space>
<InputNumber
min={0}
value={intervalHours}
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
style={{ width: 80 }}
/>{' '}
{t('pages.xray.outboundSub.hours')}
<InputNumber
min={0}
max={59}
value={intervalMinutes}
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
style={{ width: 80 }}
/>{' '}
{t('pages.xray.outboundSub.minutes')}
</Space>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.intervalHint')}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.enabled')}>
<Switch
checked={newSub.enabled}
onChange={(v) => setNewSub({ ...newSub, enabled: v })}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
<Switch
checked={newSub.allowPrivate}
onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })}
/>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.allowPrivateHint')}
</div>
</Form.Item>
<Form.Item label={t('pages.hosts.fields.allowInsecure')}>
<Switch
checked={newSub.allowInsecure}
onChange={(v) => setNewSub({ ...newSub, allowInsecure: v })}
/>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.hosts.hints.allowInsecure')}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.prepend')}>
<Switch
checked={newSub.prepend}
onChange={(v) => setNewSub({ ...newSub, prepend: v })}
/>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.prependHint')}
</div>
</Form.Item>
<Space wrap>
<Button
type="primary"
onClick={saveSub}
loading={savingSub}
icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}
>
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
</Button>
<Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
{t('pages.xray.outboundSub.preview')}
</Button>
{editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
</Space>
{previewData && previewData.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
{previewData.length} · {t('pages.xray.Outbounds')}
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
maxHeight: 120,
overflow: 'auto',
}}
>
{previewData.map((o, i) => (
<Tag key={i}>
{o?.tag || '—'}
{o?.protocol ? ` · ${o.protocol}` : ''}
</Tag>
))}
</div>
</div>
)}
</Form>
</div>
<div>
<div
style={{
fontWeight: 600,
marginBottom: 8,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{t('pages.xray.outboundSub.active')}
<Button
aria-label={t('refresh')}
size="small"
icon={<ReloadOutlined />}
onClick={loadSubs}
loading={subsLoading}
/>
{subs.length > 0 && (
<Button
size="small"
type="primary"
icon={<ReloadOutlined />}
onClick={refreshAllSubs}
loading={refreshingAll}
>
{t('pages.xray.outboundSub.refreshAll')}
</Button>
)}
</div>
{subs.length === 0 ? (
<div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
) : (
<Table
size="small"
dataSource={subs}
rowKey={(r) => r.id}
pagination={false}
scroll={{ x: true }}
columns={[
{
title: '',
key: 'order',
width: 56,
render: (_: unknown, r: OutboundSub, index: number) => (
<Space size={0}>
<Button
aria-label={t('pages.inbounds.form.moveUp')}
type="text"
size="small"
icon={<ArrowUpOutlined />}
disabled={index === 0 || busyId === r.id}
onClick={() => moveSub(r.id, 'up')}
/>
<Button
aria-label={t('pages.inbounds.form.moveDown')}
type="text"
size="small"
icon={<ArrowDownOutlined />}
disabled={index === subs.length - 1 || busyId === r.id}
onClick={() => moveSub(r.id, 'down')}
/>
</Space>
),
},
{
title: t('pages.xray.outboundSub.colRemark'),
key: 'remark',
render: (_: unknown, r: OutboundSub) => (
<div>
<div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
{r.tagPrefix && (
<div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>
)}
</div>
),
},
{
title: t('pages.xray.Outbounds'),
dataIndex: 'outboundCount',
key: 'outboundCount',
align: 'center',
render: (v) => v ?? 0,
},
{
title: t('status'),
key: 'status',
align: 'center',
render: (_: unknown, r: OutboundSub) =>
r.lastError ? (
<Tooltip title={r.lastError}>
<WarningOutlined style={{ color: '#e04141' }} />
</Tooltip>
) : (
<Tooltip title={t('pages.xray.outboundSub.statusOk')}>
<CheckCircleOutlined style={{ color: '#008771' }} />
</Tooltip>
),
},
{
title: t('pages.xray.outboundSub.colLastFetch'),
dataIndex: 'lastUpdated',
key: 'lastUpdated',
render: (v: number) =>
v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never'),
},
{
title: t('pages.xray.outboundSub.colEnabled'),
key: 'enabled',
align: 'center',
render: (_: unknown, r: OutboundSub) => (
<Switch
size="small"
checked={!!r.enabled}
loading={busyId === r.id}
onChange={() => toggleEnabled(r)}
/>
),
},
{
title: '',
key: 'actions',
render: (_: unknown, r: OutboundSub) => (
<Space>
<Button
aria-label={t('edit')}
size="small"
icon={<EditOutlined />}
onClick={() => openEditSub(r)}
title={t('edit')}
/>
<Button
aria-label={t('pages.xray.outboundSub.refreshNow')}
size="small"
icon={<ReloadOutlined />}
loading={refreshingId === r.id}
onClick={() => refreshOne(r.id)}
title={t('pages.xray.outboundSub.refreshNow')}
/>
<Popconfirm
title={t('pages.xray.outboundSub.deleteConfirm')}
okText={t('delete')}
cancelText={t('cancel')}
onConfirm={() => deleteOne(r.id)}
>
<Button
aria-label={t('delete')}
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
),
},
]}
/>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
{t('pages.xray.outboundSub.restartHint')}
</div>
</div>
</Space>
</Modal>
</>
);
}
@@ -1,156 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
interface SubscriptionOutboundsProps {
subscriptionOutbounds: unknown[];
outboundsTraffic: OutboundTrafficRow[];
isMobile: boolean;
}
// Read-only view of outbounds imported from active subscriptions. They are not
// part of the editable template (so no edit/delete/move), but traffic is matched
// by tag.
export default function SubscriptionOutbounds({
subscriptionOutbounds,
outboundsTraffic,
isMobile,
}: SubscriptionOutboundsProps) {
const { t } = useTranslation();
const rows = useMemo<OutboundRow[]>(
() =>
(subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
[subscriptionOutbounds],
);
if (rows.length === 0) return null;
const identityCell = (record: OutboundRow) => (
<div className="identity-cell">
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag || '—'}</span>
</Tooltip>
<div className="protocol-line">
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
record.protocol as never,
) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && (
<Tag color="purple">{record.streamSettings?.security}</Tag>
)}
</>
)}
</div>
</div>
);
const addressCell = (record: OutboundRow) => {
const addrs = outboundAddresses(record);
return (
<div className="address-list">
{addrs.length === 0 ? (
<span className="empty">—</span>
) : (
addrs.map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))
)}
</div>
);
};
const trafficCell = (record: OutboundRow) => {
const tr = trafficFor(outboundsTraffic, record);
return (
<>
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
<span className="traffic-sep" />
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
</>
);
};
const header = (
<div className="subscription-outbounds-head">
<div className="subscription-outbounds-title">
{t('pages.xray.outboundSub.fromSubsTitle')}
</div>
<div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
</div>
);
if (isMobile) {
return (
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
{header}
{rows.map((record, index) => (
<div key={record.key} className="outbound-card">
<div className="card-head">
<div className="card-identity">
<span className="card-num">{index + 1}</span>
{identityCell(record)}
</div>
</div>
{outboundAddresses(record).length > 0 && addressCell(record)}
<div className="card-foot">{trafficCell(record)}</div>
</div>
))}
</div>
);
}
const columns: ColumnsType<OutboundRow> = [
{
title: '#',
key: 'num',
align: 'center',
width: 60,
render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
},
{
title: t('pages.xray.outbound.tag'),
key: 'identity',
align: 'left',
render: (_v, record) => identityCell(record),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => addressCell(record),
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'left',
width: 200,
render: (_v, record) => trafficCell(record),
},
];
return (
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
{header}
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
/>
</div>
);
}
@@ -1,2 +0,0 @@
export { default as OutboundsTab } from './OutboundsTab';
export { default as OutboundFormModal } from './OutboundFormModal';
@@ -1,78 +0,0 @@
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
/**
* Translate a table row's positional index into that outbound's index in the
* full, unfiltered outbounds array. The table hides balancer-loopback outbounds
* but keeps each visible row's original index in `key`, so any handler that
* mutates the outbounds array (or probes an outbound by index) must map the
* positional index back through `key` or it operates on the wrong outbound once
* a hidden loopback precedes it.
*/
export function originalOutboundIndex(rows: OutboundRow[], positionalIndex: number): number {
const row = rows[positionalIndex];
return row ? row.key : positionalIndex;
}
export function outboundAddresses(o: OutboundRow): string[] {
const settings = o.settings as Record<string, unknown> | undefined;
switch (o.protocol) {
case Protocols.VMess: {
const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined;
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case Protocols.VLESS:
return [`${settings?.address || ''}:${settings?.port || ''}`];
case Protocols.HTTP:
case Protocols.Socks:
case Protocols.Shadowsocks:
case Protocols.Trojan: {
const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined;
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case Protocols.DNS: {
const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
const port =
(settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
return addr || port ? [`${addr}:${port}`] : [];
}
case Protocols.Wireguard:
return ((settings?.peers as Array<{ endpoint?: string }>) || [])
.map((p) => p.endpoint || '')
.filter(Boolean);
default:
return [];
}
}
export function showSecurity(security?: string): boolean {
return security === 'tls' || security === 'reality';
}
export function trafficFor(
outboundsTraffic: OutboundTrafficRow[],
o: OutboundRow,
): { up: number; down: number } {
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
return { up: tr?.up || 0, down: tr?.down || 0 };
}
export function countryFlag(country?: string): string {
const code = (country || '').trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return '';
return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + ch.charCodeAt(0) - 65));
}
export function countryName(country?: string, locale?: string): string {
const code = (country || '').trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return '';
try {
return (
new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code
);
} catch {
return code;
}
}
@@ -1,7 +0,0 @@
export interface OutboundRow {
key: number;
tag?: string;
protocol?: string;
streamSettings?: { network?: string; security?: string };
settings?: Record<string, unknown>;
}
@@ -1,19 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Select } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function BlackholeFields() {
const { t } = useTranslation();
return (
<FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
<Select
options={[
{ value: '', label: '(empty)' },
{ value: 'none', label: 'none' },
{ value: 'http', label: 'http' },
]}
/>
</FormField>
);
}
@@ -1,84 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { DNSRuleActions } from '@/schemas/primitives';
export default function DnsFields() {
const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
return (
<>
<FormField
label={t('pages.xray.outboundForm.rewriteNetwork')}
name={['settings', 'rewriteNetwork']}
>
<Select
allowClear
placeholder={t('pages.xray.outboundForm.unchanged')}
options={[
{ value: 'udp', label: 'udp' },
{ value: 'tcp', label: 'tcp' },
]}
/>
</FormField>
<FormField
label={t('pages.inbounds.form.rewriteAddress')}
name={['settings', 'rewriteAddress']}
>
<Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
</FormField>
<FormField label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</FormField>
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.xray.outboundForm.rules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => append({ action: 'direct', qType: '', domain: '', rCode: 0 })}
/>
</Form.Item>
{fields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(index)}
onKeyDown={activateOnKey(() => remove(index))}
/>
</div>
</Form.Item>
<FormField
label={t('pages.xray.outboundForm.action')}
name={['settings', 'rules', index, 'action']}
>
<Select options={DNSRuleActions.map((a) => ({ value: a, label: a }))} />
</FormField>
<FormField label="QType" name={['settings', 'rules', index, 'qType']}>
<Input placeholder="1,3,23-24" />
</FormField>
<FormField label={t('domainName')} name={['settings', 'rules', index, 'domain']}>
<Input placeholder="domain:example.com" />
</FormField>
<FormField label="RCode" name={['settings', 'rules', index, 'rCode']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</FormField>
</div>
))}
</>
);
}
@@ -1,289 +0,0 @@
import { useTranslation } from 'react-i18next';
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives';
interface FragmentValue {
packets?: string;
length?: string;
interval?: string;
maxSplit?: string;
}
export default function FreedomFields() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const fragment = (useWatch({ control, name: 'settings.fragment' }) ?? {}) as FragmentValue;
const fragmentEnabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
const {
fields: noiseFields,
append: appendNoise,
remove: removeNoise,
} = useFieldArray({ control, name: 'settings.noises' });
const {
fields: finalRuleFields,
append: appendFinalRule,
remove: removeFinalRule,
} = useFieldArray({ control, name: 'settings.finalRules' });
const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as {
action?: string;
}[];
return (
<>
<FormField
label={t('pages.xray.balancer.balancerStrategy')}
name={['settings', 'domainStrategy']}
>
<Select
options={[
{ value: '', label: `(${t('none')})` },
...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
]}
/>
</FormField>
<FormField label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
<Input />
</FormField>
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<FormField
label={t('pages.xray.outboundForm.proxyProtocol')}
name={['settings', 'proxyProtocol']}
>
<Select
options={[
{ value: 0, label: `(${t('none')})` },
{ value: 1, label: 'v1' },
{ value: 2, label: 'v2' },
]}
/>
</FormField>
<Form.Item label="Fragment">
<Switch
checked={fragmentEnabled}
onChange={(checked) => {
setValue(
'settings.fragment',
checked
? {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
}
: { packets: '', length: '', interval: '', maxSplit: '' },
);
}}
/>
</Form.Item>
{fragmentEnabled && (
<>
<FormField
label={t('pages.settings.subFormats.packets')}
name={['settings', 'fragment', 'packets']}
rules={{
validate: (value) => {
const str = String(value ?? '').trim();
/* xray accepts "tlshello" or any packet-number range (#5075) */
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) return true;
return 'Use "tlshello" or a packet range like 1-3';
},
}}
>
<AutoComplete
options={[
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]}
placeholder="tlshello or n-m, e.g. 1-3"
/>
</FormField>
<FormField
label={t('pages.settings.subFormats.length')}
name={['settings', 'fragment', 'length']}
>
<Input />
</FormField>
<FormField
label={t('pages.settings.subFormats.interval')}
name={['settings', 'fragment', 'interval']}
>
<Input />
</FormField>
<FormField
label={t('pages.settings.subFormats.maxSplit')}
name={['settings', 'fragment', 'maxSplit']}
>
<Input />
</FormField>
</>
)}
<Form.Item label={t('pages.settings.subFormats.noises')}>
<Switch
checked={noiseFields.length > 0}
onChange={(checked) => {
if (checked) {
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' });
} else {
removeNoise();
}
}}
/>
{noiseFields.length > 0 && (
<Button
size="small"
type="primary"
className="ml-8"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })
}
/>
)}
</Form.Item>
{noiseFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
{noiseFields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeNoise(index)}
onKeyDown={activateOnKey(() => removeNoise(index))}
/>
)}
</div>
</Form.Item>
<FormField
label={t('pages.settings.subFormats.type')}
name={['settings', 'noises', index, 'type']}
>
<Select
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField
label={t('pages.settings.subFormats.packet')}
name={['settings', 'noises', index, 'packet']}
>
<Input />
</FormField>
<FormField
label={t('pages.settings.subFormats.delayMs')}
name={['settings', 'noises', index, 'delay']}
>
<Input />
</FormField>
<FormField
label={t('pages.settings.subFormats.applyTo')}
name={['settings', 'noises', index, 'applyTo']}
>
<Select
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
</div>
))}
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })
}
/>
<span className="ml-8" style={{ opacity: 0.6 }}>
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
</span>
</Form.Item>
{finalRuleFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeFinalRule(index)}
onKeyDown={activateOnKey(() => removeFinalRule(index))}
/>
</div>
</Form.Item>
<FormField
label={t('pages.xray.outboundForm.action')}
name={['settings', 'finalRules', index, 'action']}
>
<Select
options={['allow', 'block'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField
label={t('pages.inbounds.network')}
name={['settings', 'finalRules', index, 'network']}
>
<Select
allowClear
placeholder="(any)"
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField
label={t('pages.inbounds.port')}
name={['settings', 'finalRules', index, 'port']}
>
<Input placeholder="e.g. 80,443 or 1000-2000" />
</FormField>
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="e.g. 10.0.0.0/8, geoip:private"
/>
</FormField>
{finalRulesValues[index]?.action === 'block' && (
<FormField
label={t('pages.xray.outboundForm.blockDelay')}
name={['settings', 'finalRules', index, 'blockDelay']}
>
<Input placeholder="optional: 5000-10000" />
</FormField>
)}
</div>
))}
</>
);
}
@@ -1,22 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function HttpFields() {
const { t } = useTranslation();
return (
<>
<FormField label={t('username')} name={['settings', 'user']}>
<Input />
</FormField>
<FormField label={t('password')} name={['settings', 'pass']}>
<Input />
</FormField>
<FormField label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
<HeaderMapEditor mode="v1" />
</FormField>
</>
);
}
@@ -1,12 +0,0 @@
export { default as ServerTarget } from './server-target';
export { default as VmessFields } from './vmess';
export { default as VlessFields } from './vless';
export { default as TrojanFields } from './trojan';
export { default as ShadowsocksFields } from './shadowsocks';
export { default as HttpFields } from './http';
export { default as SocksFields } from './socks';
export { default as WireguardFields } from './wireguard';
export { default as FreedomFields } from './freedom';
export { default as LoopbackFields } from './loopback';
export { default as BlackholeFields } from './blackhole';
export { default as DnsFields } from './dns';
@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { SniffingField } from '@/lib/xray/forms/fields';
export default function LoopbackFields() {
const { t } = useTranslation();
const { control } = useFormContext();
return (
<>
<FormField label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
</FormField>
<Controller
control={control}
name="settings.sniffing"
render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('pages.inbounds.sniffingTab')}
/>
)}
/>
</>
);
}
@@ -1,28 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input, InputNumber } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function ServerTarget() {
const { t } = useTranslation();
return (
<>
<FormField
label={t('pages.inbounds.address')}
name={['settings', 'address']}
required
rules={{ required: 'pages.xray.outboundForm.addressRequired' }}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.port')}
name={['settings', 'port']}
required
rules={{ required: 'pages.xray.outboundForm.portRequired' }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</FormField>
</>
);
}

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