From 92fb94d85675ae914d1e3b07b155828b7be34d2f Mon Sep 17 00:00:00 2001 From: Sanaei Date: Wed, 19 Aug 2026 15:36:27 +0200 Subject: [PATCH] Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint TypeScript 7 is the native Go port and ships no programmatic compiler API, so typescript-eslint cannot run at all: it peer-pins typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with "typescript-eslint does not support TS 7.0". Upstream support is tracked in typescript-eslint#10940 and targets TS >=7.1. Rather than wait, or carry Microsoft's side-by-side alias (which keeps a second TS 6 install alive purely to feed the linter), both projects move to oxlint, which never depended on the TypeScript API. Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/. oxlint has no no-restricted-syntax, so the #6121/#6127 cleared- InputNumber guard is reimplemented as a JS plugin in frontend/tools/oxlint/. It was verified to still fire in pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx. The type-aware @deprecated sweep survives too, as `npm run lint:deprecated`: oxlint's type-aware mode runs on oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the TS 7 move is what makes it possible. Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role is off in both configs because it was never part of the recommended sets ESLint actually ran, and oxlint honours the existing eslint-disable comments, so no source churn was needed. Two real fixes fell out of the stricter linting: - outbound-link-parser.test.ts used `out?.streamSettings` behind an `as` cast, which hid the optional chain from ESLint and would throw on a null parse; the rest of the file already used `out!`. - InputAddon's conditional role/tabIndex/onKeyDown is genuinely accessible but oxlint cannot evaluate it, so it gets a scoped disable. * chore(docs): replace Prettier with oxfmt oxfmt is the oxc project's Prettier-compatible formatter, so this pairs with the oxlint move and drops the last JS-based tool from the docs toolchain. The swap is behaviour-preserving. Running Prettier and oxfmt over the same files, with the existing .prettierrc.json settings migrated via `oxfmt --migrate=prettier`, produces byte-identical output on every file. (Comparing them outside the project directory is misleading: Prettier silently falls back to its defaults when it cannot find its config, which looks like a mismatch but is not one.) The 18 files reformatted here were already failing `pnpm format:check` before this change — Prettier wanted the exact same edits. The check is not part of docs-ci.yml, which is why the drift went unnoticed. .prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the deliberate MDX exclusion: reflowing MDX prose merges headings into paragraphs and collapses lists inside Steps/Callout components. Both that and the generated fumadocs-openapi reference output were verified untouched. oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in minimumReleaseAge policy rejects same-day releases, and 0.64.0 would have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml. * style(frontend): adopt oxfmt and format src frontend/ has never had a formatter, so this reformats 344 of 497 files in src/. The change is purely whitespace, quoting and line wrapping — no logic is touched. It is kept in its own commit so it does not bury the TypeScript 7 / oxlint migration or the git blame for the code itself. Settings match docs/ and the code as it was already written: single quotes, semicolons, trailing commas, 2-space indent, 100 columns. That was measured rather than assumed — src/ was already uniformly single-quoted and 2-space indented, with p90 line length at 75. Formatting is scoped to src/ (mirroring `oxlint src`) and .oxfmtrc.json ignores src/generated. Both matter: `make gen-check` compares src/generated and public/openapi.json, and `make msw-worker-check` byte-compares public/mockServiceWorker.js against the installed MSW runtime, so reformatting any of them breaks the gate. Reflowing also moves `eslint-disable-next-line` comments off the line they guard, which broke two suppressions that had been silently correct before: - clone-inbound-modal.test.tsx: the object literal became multi-line, leaving `} as any;` four lines below its no-explicit-any disable. - ClientsPage.tsx: the useMemo dependency array moved onto its own line, out from under its exhaustive-deps disable. Both comments were relocated onto the line they actually guard, and verified to still suppress by removing them and watching the errors return. * ci: enforce formatting in CI and make verify Adding oxfmt in the previous two commits gave both projects a formatter but nothing that checks it, which is how docs/ had already drifted to 18 unformatted files: docs-ci.yml runs typecheck, lint, test and build, but never format:check, so Prettier's complaints were only ever visible to whoever ran it by hand. Wire `format:check` into the frontend job in ci.yml and the docs job in docs-ci.yml, and add a `format-check` target to `make verify` so the local gate keeps mirroring CI as the Makefile header promises. Verified the step actually bites rather than passing vacuously: adding a badly formatted line to a source file in each project makes both `make format-check` and `pnpm format:check` fail, and reverting it makes them pass again. No workflow referenced ESLint or Prettier by name — they all invoke the package scripts — so the tooling swap needed no other CI changes. * ci: trigger CI on Makefile changes The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and ci.yml itself, but not the Makefile — so a change to the canonical task runner that ci.yml is meant to mirror could land without any job running. The previous commit, which edits both, only triggers because it happens to touch ci.yml too. * fix(frontend): replace deprecated Ant Design 6 APIs in the geo components `npm run lint:deprecated` reported five uses of props Ant Design 6 has deprecated. All five are gone, and the matching runtime warnings no longer appear in the test output. Tag `bordered={false}` becomes `variant="filled"` and Space `direction` becomes `orientation`; both are the one-to-one replacements named in antd's own deprecation messages, and `direction`/`orientation` share the same Orientation type. Input `addonAfter` is the one that is not a rename. It becomes a `Space.Compact block` wrapping the Input and the browse Button, which is antd's documented migration. `block` keeps the field filling its form row as the addon did. Note this is a deliberate visual change: the button used to be a borderless `type="text"` icon sitting inside the addon's grey box, and is now a regular button whose border joins the input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged, so the react-hook-form binding in RuleFormModal and the existing tests still address it the same way. Only these five were deprecated. The other `bordered` props in the tree sit on QRCode, Table, Descriptions and Alert, where the prop is not deprecated, and these were the only two Space `direction` uses in the codebase. * fix(frontend): restore lint rules lost in the oxlint migration, and test the guard Addresses the review on #6262. The frontend config re-enabled only no-explicit-any and no-unused-vars and left the rest of tseslint's recommended set to oxlint's correctness category. It does not cover all of it. Confirmed by linting one probe file against both configs: docs/ (which enumerates the rules) reports all nine, frontend/ reported four. So ban-ts-comment, no-empty-object-type, no-namespace, no-require-imports and no-unsafe-function-type had silently stopped being enforced — a `// @ts-ignore` or a `namespace` block would have landed unflagged. The ten rules are now mirrored from docs/.oxlintrc.json, and src/ still passes. The #6121/#6127 guard was 57 lines of hand-written AST walking with no test. It now has one: fixtures for the three banned shapes plus an onNumber()-wrapped control, asserting the rule fires three times and that .oxlintrc.json still wires it to the right paths. Verified it fails for the right reason by making walk() enumerate nothing, which is the silent-death mode the review described — the traversal depends on Object.keys() seeing AST children as own enumerable properties. The fixtures deliberately violate the rule, so their oxlint config is named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers nested configs by directory, which would otherwise turn the fixtures into three lint errors. The test passes it explicitly with -c. Also from the review: - lint and format now cover tools/ as well as src/, so the one piece of hand-written lint logic in the repo is no longer the least covered file in it. - lint-staged runs oxfmt before oxlint --fix. Formatting became a hard CI gate in this PR while the hook only ran the linter, so a commit could pass the hook and fail CI on formatting alone. - .oxfmtrc.json ignores public/, so the artefacts that make gen-check and make msw-worker-check byte-compare stay safe even if oxfmt is invoked without a path argument. - The MDX and generated-reference rationales that .prettierignore carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt both accept JSONC, so relocating them was unnecessary. Not applied: the review also suggested restoring ../internal/web/dist to the ignore lists. Both tools reject `..` patterns outright ("patterns are resolved within the config file's directory"), and being outside frontend/ it is unreachable anyway. --- .github/workflows/ci.yml | 5 + .github/workflows/docs-ci.yml | 3 + CLAUDE.md | 5 +- CONTRIBUTING.md | 9 +- Makefile | 12 +- docs/.oxfmtrc.json | 21 + docs/.oxlintrc.json | 42 + docs/.prettierignore | 10 - docs/.prettierrc.json | 7 - docs/CONTRIBUTING.md | 6 +- docs/README.md | 32 +- docs/architecture.md | 194 +- docs/components/tools/api-request-builder.tsx | 7 +- docs/components/tools/routing-builder.tsx | 83 +- .../components/tools/subscription-builder.tsx | 45 +- docs/custom-subscription-templates.md | 44 +- docs/eslint.config.mjs | 21 - docs/lib/layout.shared.tsx | 9 +- docs/lib/site-i18n.ts | 3 +- docs/lib/xray/api-client.test.ts | 26 +- docs/lib/xray/outbounds.test.ts | 7 +- docs/lib/xray/outbounds.ts | 6 +- docs/lib/xray/routing.test.ts | 7 +- docs/lib/xray/routing.ts | 5 +- docs/lib/xray/subscription.ts | 12 +- docs/lib/xray/telegram.test.ts | 17 +- docs/lib/xray/telegram.ts | 5 +- docs/package.json | 14 +- docs/pnpm-lock.yaml | 3508 +++------------ docs/real-client-ip.md | 20 +- frontend/.oxfmtrc.json | 14 + frontend/.oxlintrc.json | 72 + frontend/CLAUDE.md | 2 +- frontend/README.md | 29 +- frontend/eslint.config.js | 89 - frontend/eslint.deprecated.config.js | 26 - frontend/package-lock.json | 3878 ++++++----------- frontend/package.json | 24 +- frontend/src/api/http-init.ts | 4 +- frontend/src/api/queries/useAllSettings.ts | 36 +- .../src/api/queries/useFactoryDefaults.ts | 4 +- .../src/api/queries/useFail2banStatusQuery.ts | 4 +- frontend/src/api/queries/useGeodata.ts | 13 +- frontend/src/api/queries/useHostMutations.ts | 43 +- frontend/src/api/queries/useNodeMutations.ts | 43 +- frontend/src/api/queries/useOutboundTags.ts | 8 +- frontend/src/api/queries/useStatusQuery.ts | 4 +- frontend/src/api/queryKeys.ts | 3 +- frontend/src/api/websocket.ts | 19 +- .../components/clients/ClientCardComment.tsx | 7 +- .../src/components/clients/ClientSpeedTag.tsx | 3 +- .../clients/ClientTrafficCell.stories.tsx | 5 +- .../components/clients/ClientTrafficCell.tsx | 10 +- .../clients/ConfigBlock.stories.tsx | 22 +- .../src/components/clients/ConfigBlock.tsx | 18 +- .../feedback/PromptModal.stories.tsx | 4 +- .../src/components/feedback/PromptModal.tsx | 6 +- .../components/feedback/TextModal.stories.tsx | 9 +- .../src/components/feedback/TextModal.tsx | 76 +- .../src/components/form/DateTimePicker.css | 4 +- .../form/DateTimePicker.stories.tsx | 4 +- .../src/components/form/DateTimePicker.tsx | 5 +- .../form/HeaderMapEditor.stories.tsx | 23 +- .../src/components/form/HeaderMapEditor.tsx | 16 +- frontend/src/components/form/JsonEditor.tsx | 5 +- .../form/RemarkTemplateField.stories.tsx | 14 +- .../components/form/RemarkTemplateField.tsx | 29 +- .../form/RemarkVarPicker.stories.tsx | 9 +- .../src/components/form/RemarkVarPicker.tsx | 59 +- .../form/SelectAllClearButtons.stories.tsx | 20 +- .../components/form/SelectAllClearButtons.tsx | 6 +- .../components/form/rhf/FormField.stories.tsx | 29 +- .../src/components/form/rhf/useZodForm.ts | 4 +- .../geodata/GeoBrowserModal.stories.tsx | 615 ++- .../components/geodata/GeoBrowserModal.tsx | 98 +- .../geodata/GeoTokenInput.stories.tsx | 85 +- .../src/components/geodata/GeoTokenInput.tsx | 48 +- .../src/components/ui/DefaultSettingTag.tsx | 5 +- frontend/src/components/ui/InputAddon.tsx | 11 +- .../src/components/ui/SettingListItem.tsx | 18 +- .../EmailNotifications.stories.tsx | 11 +- .../ui/notifications/EmailNotifications.tsx | 57 +- .../ui/notifications/NotificationCard.tsx | 6 +- .../ui/notifications/NotificationEvent.tsx | 6 +- .../NotificationGroup.stories.tsx | 43 +- .../ui/notifications/NotificationGroup.tsx | 12 +- .../NotificationHeader.stories.tsx | 4 +- .../ui/notifications/NotificationHeader.tsx | 33 +- .../NotificationLayout.stories.tsx | 68 +- .../ui/notifications/NotificationLayout.tsx | 8 +- .../TelegramNotifications.stories.tsx | 10 +- .../notifications/TelegramNotifications.tsx | 57 +- .../src/components/ui/notifications/types.ts | 6 +- .../components/utility/LazyMount.stories.tsx | 15 +- .../src/components/viz/Sparkline.stories.tsx | 5 +- frontend/src/components/viz/Sparkline.tsx | 32 +- frontend/src/hooks/useClients.ts | 622 ++- frontend/src/hooks/useServerDraft.ts | 11 +- frontend/src/hooks/useXraySetting.ts | 276 +- frontend/src/i18n/react.ts | 9 +- frontend/src/layouts/AppSidebar.css | 24 +- frontend/src/layouts/AppSidebar.tsx | 195 +- frontend/src/lib/clients/traffic-display.ts | 5 +- frontend/src/lib/hosts/host-link.ts | 4 +- frontend/src/lib/remark/remarkVariables.ts | 21 +- .../src/lib/xray/forms/SniffingFields.tsx | 24 +- .../lib/xray/forms/fields/FinalMaskField.tsx | 16 +- .../xray/forms/transport/FinalMaskForm.tsx | 324 +- frontend/src/lib/xray/geoTokens.ts | 6 +- frontend/src/lib/xray/inbound-clone.ts | 18 +- frontend/src/lib/xray/inbound-defaults.ts | 49 +- frontend/src/lib/xray/inbound-form-adapter.ts | 45 +- frontend/src/lib/xray/inbound-from-db.ts | 12 +- frontend/src/lib/xray/inbound-link.ts | 250 +- frontend/src/lib/xray/inbound-tag.ts | 5 +- frontend/src/lib/xray/inbound-tls-defaults.ts | 7 +- frontend/src/lib/xray/link-label.tsx | 28 +- frontend/src/lib/xray/outbound-defaults.ts | 77 +- .../src/lib/xray/outbound-form-adapter.ts | 262 +- frontend/src/lib/xray/outbound-link-parser.ts | 161 +- .../src/lib/xray/protocol-capabilities.ts | 10 +- frontend/src/lib/xray/stream-defaults.ts | 38 +- .../src/lib/xray/stream-wire-normalize.ts | 19 +- frontend/src/models/dbinbound.ts | 407 +- frontend/src/models/setting.ts | 4 +- frontend/src/pages/api-docs/ApiDocsPage.css | 10 +- frontend/src/pages/api-docs/endpoints.ts | 1413 ++++-- .../src/pages/clients/BulkAddToGroupModal.tsx | 4 +- .../pages/clients/BulkAttachInboundsModal.tsx | 10 +- .../pages/clients/BulkDetachInboundsModal.tsx | 10 +- .../src/pages/clients/ClientBulkAddModal.tsx | 86 +- .../pages/clients/ClientBulkAdjustModal.tsx | 21 +- .../src/pages/clients/ClientFormModal.tsx | 567 ++- .../src/pages/clients/ClientInfoModal.css | 4 +- .../src/pages/clients/ClientInfoModal.tsx | 312 +- frontend/src/pages/clients/ClientQrModal.tsx | 60 +- frontend/src/pages/clients/ClientsPage.css | 20 +- frontend/src/pages/clients/ClientsPage.tsx | 1274 ++++-- frontend/src/pages/clients/FilterDrawer.tsx | 37 +- frontend/src/pages/clients/RowCells.tsx | 8 +- frontend/src/pages/clients/SubLinksModal.tsx | 27 +- frontend/src/pages/clients/wireguardConfig.ts | 17 +- .../src/pages/groups/GroupAddClientsModal.tsx | 6 +- frontend/src/pages/groups/GroupsPage.tsx | 89 +- frontend/src/pages/hosts/HostFormModal.tsx | 214 +- frontend/src/pages/hosts/HostList.tsx | 110 +- frontend/src/pages/hosts/HostsPage.tsx | 120 +- .../hosts/json-forms/HostFinalMaskForm.tsx | 8 +- .../pages/hosts/json-forms/HostMuxForm.tsx | 19 +- .../hosts/json-forms/HostSockoptForm.tsx | 8 +- .../src/pages/inbounds/CloneInboundModal.tsx | 54 +- frontend/src/pages/inbounds/InboundsPage.tsx | 720 +-- .../clients/AddClientsToGroupModal.tsx | 8 +- .../inbounds/clients/AttachClientsModal.tsx | 8 +- .../clients/AttachExistingClientsModal.tsx | 17 +- .../inbounds/clients/DetachClientsModal.tsx | 4 +- .../src/pages/inbounds/form/FallbacksCard.tsx | 18 +- .../pages/inbounds/form/InboundFormModal.tsx | 361 +- .../src/pages/inbounds/form/SniffingTab.tsx | 6 +- .../pages/inbounds/form/advanced-editors.tsx | 19 +- .../inbounds/form/formatValidationError.ts | 6 +- .../inbounds/form/protocols/accounts-list.tsx | 10 +- .../inbounds/form/protocols/hysteria.tsx | 45 +- .../pages/inbounds/form/protocols/mixed.tsx | 6 +- .../pages/inbounds/form/protocols/mtproto.tsx | 15 +- .../inbounds/form/protocols/shadowsocks.tsx | 10 +- .../pages/inbounds/form/protocols/tunnel.tsx | 10 +- .../pages/inbounds/form/protocols/vless.tsx | 4 +- .../inbounds/form/protocols/wireguard.tsx | 5 +- .../security/RealityTargetScannerModal.tsx | 4 +- .../pages/inbounds/form/security/reality.tsx | 74 +- .../src/pages/inbounds/form/security/tls.tsx | 88 +- .../inbounds/form/transport/httpupgrade.tsx | 10 +- .../src/pages/inbounds/form/transport/kcp.tsx | 15 +- .../src/pages/inbounds/form/transport/raw.tsx | 28 +- .../pages/inbounds/form/transport/sockopt.tsx | 19 +- .../pages/inbounds/form/transport/xhttp.tsx | 34 +- .../inbounds/form/useInboundFallbacks.ts | 110 +- .../pages/inbounds/form/useSecurityActions.ts | 80 +- .../pages/inbounds/info/InboundInfoModal.css | 4 +- .../pages/inbounds/info/InboundInfoModal.tsx | 700 ++- frontend/src/pages/inbounds/info/helpers.ts | 39 +- .../src/pages/inbounds/list/InboundList.tsx | 186 +- .../pages/inbounds/list/InboundSpeedTag.tsx | 13 +- .../pages/inbounds/list/InboundStatsModal.tsx | 79 +- .../src/pages/inbounds/list/RowActions.tsx | 80 +- frontend/src/pages/inbounds/list/helpers.ts | 27 +- .../pages/inbounds/list/useInboundColumns.tsx | 212 +- .../src/pages/inbounds/qr/QrCodeModal.tsx | 52 +- frontend/src/pages/inbounds/qr/QrPanel.tsx | 23 +- frontend/src/pages/inbounds/useInbounds.ts | 168 +- frontend/src/pages/index/BackupModal.tsx | 48 +- frontend/src/pages/index/ConnectionsCard.tsx | 8 +- frontend/src/pages/index/IndexPage.css | 37 +- frontend/src/pages/index/IndexPage.tsx | 41 +- frontend/src/pages/index/LogModal.css | 2 +- frontend/src/pages/index/LogModal.tsx | 21 +- .../src/pages/index/OverviewActionBar.tsx | 58 +- frontend/src/pages/index/PanelUpdateModal.tsx | 42 +- .../src/pages/index/SystemHistoryModal.css | 6 +- .../src/pages/index/SystemHistoryModal.tsx | 148 +- frontend/src/pages/index/SystemStrip.tsx | 4 +- frontend/src/pages/index/ThroughputCard.tsx | 8 +- frontend/src/pages/index/VersionModal.tsx | 13 +- frontend/src/pages/index/XrayLogModal.css | 2 +- frontend/src/pages/index/XrayLogModal.tsx | 41 +- frontend/src/pages/index/XrayMetricsModal.css | 14 +- frontend/src/pages/index/XrayMetricsModal.tsx | 138 +- .../src/pages/index/useOverviewHistory.ts | 16 +- frontend/src/pages/login/LoginPage.css | 95 +- frontend/src/pages/login/LoginPage.tsx | 32 +- frontend/src/pages/nodes/NodeFormModal.tsx | 62 +- frontend/src/pages/nodes/NodeHistoryPanel.tsx | 2 +- frontend/src/pages/nodes/NodeList.tsx | 790 ++-- frontend/src/pages/nodes/NodesPage.tsx | 213 +- frontend/src/pages/settings/EmailTab.tsx | 277 +- frontend/src/pages/settings/GeneralTab.tsx | 844 ++-- frontend/src/pages/settings/SecurityTab.tsx | 274 +- frontend/src/pages/settings/SettingsPage.tsx | 77 +- .../pages/settings/SubscriptionFormatsTab.tsx | 462 +- .../pages/settings/SubscriptionGeneralTab.tsx | 608 ++- frontend/src/pages/settings/TelegramTab.tsx | 256 +- .../src/pages/settings/TwoFactorModal.tsx | 106 +- frontend/src/pages/settings/catTabLabel.tsx | 7 +- frontend/src/pages/settings/uriPath.ts | 3 +- frontend/src/pages/sub/SubPage.css | 5 +- frontend/src/pages/sub/SubPage.tsx | 332 +- frontend/src/pages/xray/XrayPage.tsx | 58 +- .../xray/balancers/BalancerFormModal.tsx | 100 +- .../src/pages/xray/balancers/BalancersTab.tsx | 132 +- .../xray/balancers/ObservatorySettingsTab.tsx | 24 +- .../pages/xray/balancers/balancer-helpers.ts | 11 +- .../pages/xray/balancers/balancer-loopback.ts | 31 +- frontend/src/pages/xray/basics/BasicsTab.tsx | 182 +- frontend/src/pages/xray/basics/constants.ts | 6 +- frontend/src/pages/xray/basics/helpers.ts | 13 +- .../src/pages/xray/dns/DnsPresetsModal.tsx | 5 +- .../src/pages/xray/dns/DnsServerModal.tsx | 100 +- frontend/src/pages/xray/dns/DnsTab.tsx | 267 +- frontend/src/pages/xray/dns/useDnsColumns.tsx | 30 +- .../pages/xray/outbounds/OutboundCardList.tsx | 91 +- .../xray/outbounds/OutboundFormModal.tsx | 85 +- .../src/pages/xray/outbounds/OutboundsTab.tsx | 302 +- .../xray/outbounds/SubscriptionOutbounds.tsx | 74 +- .../xray/outbounds/TestResultPopover.tsx | 28 +- .../xray/outbounds/outbound-form-constants.ts | 17 +- .../xray/outbounds/outbound-form-helpers.ts | 34 +- .../xray/outbounds/outbounds-tab-helpers.ts | 39 +- .../pages/xray/outbounds/protocols/dns.tsx | 19 +- .../xray/outbounds/protocols/freedom.tsx | 82 +- .../xray/outbounds/protocols/wireguard.tsx | 25 +- .../pages/xray/outbounds/security/reality.tsx | 10 +- .../src/pages/xray/outbounds/security/tls.tsx | 20 +- .../xray/outbounds/transport/httpupgrade.tsx | 10 +- .../xray/outbounds/transport/hysteria.tsx | 13 +- .../pages/xray/outbounds/transport/kcp.tsx | 5 +- .../pages/xray/outbounds/transport/mux.tsx | 6 +- .../pages/xray/outbounds/transport/raw.tsx | 28 +- .../xray/outbounds/transport/sockopt.tsx | 19 +- .../pages/xray/outbounds/transport/xhttp.tsx | 19 +- .../xray/outbounds/useOutboundColumns.tsx | 123 +- .../src/pages/xray/overrides/NordModal.tsx | 317 +- .../src/pages/xray/overrides/WarpModal.tsx | 375 +- frontend/src/pages/xray/reference-cleanup.ts | 13 +- .../src/pages/xray/routing/CriterionRow.tsx | 12 +- .../src/pages/xray/routing/RouteTester.tsx | 24 +- .../src/pages/xray/routing/RoutingBasic.tsx | 52 +- .../src/pages/xray/routing/RoutingTab.css | 4 +- .../src/pages/xray/routing/RoutingTab.tsx | 46 +- .../src/pages/xray/routing/RuleCardList.tsx | 67 +- .../src/pages/xray/routing/RuleFormModal.tsx | 25 +- frontend/src/pages/xray/routing/helpers.ts | 10 +- .../pages/xray/routing/useRoutingColumns.tsx | 159 +- frontend/src/routes.tsx | 9 +- frontend/src/schemas/api/host.ts | 79 +- frontend/src/schemas/api/inbound.ts | 5 +- frontend/src/schemas/client.ts | 228 +- frontend/src/schemas/defaults.ts | 38 +- frontend/src/schemas/dns.ts | 26 +- frontend/src/schemas/forms/inbound-form.ts | 10 +- frontend/src/schemas/forms/outbound-form.ts | 5 +- frontend/src/schemas/inbound.ts | 20 +- frontend/src/schemas/node.ts | 175 +- frontend/src/schemas/primitives/flow.ts | 6 +- frontend/src/schemas/primitives/sniffing.ts | 4 +- .../src/schemas/protocols/inbound/hysteria.ts | 5 +- .../src/schemas/protocols/inbound/index.ts | 20 +- .../src/schemas/protocols/inbound/mtproto.ts | 5 +- .../schemas/protocols/inbound/shadowsocks.ts | 5 +- .../src/schemas/protocols/inbound/trojan.ts | 5 +- .../src/schemas/protocols/inbound/tunnel.ts | 4 +- .../src/schemas/protocols/inbound/vless.ts | 5 +- .../src/schemas/protocols/inbound/vmess.ts | 5 +- .../schemas/protocols/inbound/wireguard.ts | 5 +- .../src/schemas/protocols/outbound/index.ts | 22 +- .../src/schemas/protocols/security/index.ts | 2 +- .../src/schemas/protocols/security/tls.ts | 7 +- .../src/schemas/protocols/shared/vmess.ts | 6 +- .../src/schemas/protocols/stream/finalmask.ts | 5 +- .../src/schemas/protocols/stream/index.ts | 30 +- .../src/schemas/protocols/stream/xhttp.ts | 106 +- frontend/src/schemas/routing.ts | 5 +- frontend/src/schemas/setting.ts | 198 +- frontend/src/schemas/status.ts | 14 +- frontend/src/schemas/xray.ts | 172 +- frontend/src/styles/page-cards.css | 10 +- frontend/src/styles/page-shell.css | 18 +- frontend/src/styles/utils.css | 84 +- frontend/src/test/api-token-date.test.tsx | 4 +- .../src/test/balancer-form-modal.test.tsx | 7 +- frontend/src/test/balancer-loopback.test.ts | 22 +- .../test/balancer-observatory-sync.test.ts | 100 +- frontend/src/test/balancer.test.ts | 13 +- .../src/test/client-card-comment.test.tsx | 2 +- frontend/src/test/client-form-flow.test.tsx | 20 +- frontend/src/test/client-form-modal.test.tsx | 10 +- .../src/test/clients-query-gating.test.tsx | 8 +- .../src/test/clients-row-cells-memo.test.tsx | 18 +- frontend/src/test/clients-summary.test.tsx | 13 +- .../src/test/clone-inbound-modal.test.tsx | 33 +- .../src/test/default-setting-tag.test.tsx | 21 +- frontend/src/test/dns-tab.test.tsx | 53 +- frontend/src/test/dns.test.ts | 38 +- frontend/src/test/finalmask.test.ts | 20 +- .../src/test/format-validation-error.test.ts | 13 +- frontend/src/test/generated-examples.test.ts | 4 +- .../src/test/geo-browser-selection.test.tsx | 97 +- frontend/src/test/geo-tokens.test.ts | 65 +- .../test/golden/fixtures/dns-server/full.json | 15 +- .../src/test/golden/fixtures/dns/minimal.json | 5 +- .../golden/fixtures/finalmask/udp-mask.json | 14 +- .../fixtures/inbound-full/hysteria-tls.json | 11 +- .../golden/fixtures/inbound/mixed-basic.json | 4 +- .../src/test/golden/fixtures/rule/full.json | 39 +- frontend/src/test/headers.test.ts | 37 +- frontend/src/test/http-init-msw.test.ts | 4 +- frontend/src/test/http-init.test.tsx | 40 +- frontend/src/test/httpUtil.test.ts | 15 +- frontend/src/test/i18n-dead-keys.test.ts | 15 +- frontend/src/test/inbound-clone.test.ts | 6 +- frontend/src/test/inbound-defaults.test.ts | 15 +- .../src/test/inbound-form-adapter.test.ts | 42 +- .../src/test/inbound-form-blocks.test.tsx | 7 +- frontend/src/test/inbound-form-modal.test.tsx | 77 +- frontend/src/test/inbound-from-db.test.ts | 112 +- frontend/src/test/inbound-full.test.ts | 13 +- frontend/src/test/inbound-link.test.ts | 223 +- frontend/src/test/inbound-tag.test.ts | 63 +- frontend/src/test/input-number-guard.test.ts | 58 + frontend/src/test/log-parse.test.ts | 4 +- .../src/test/mtproto-clients-link.test.ts | 10 +- .../test/observatory-settings-tab.test.tsx | 13 +- frontend/src/test/outbound-defaults.test.ts | 63 +- .../src/test/outbound-form-adapter.test.ts | 375 +- .../src/test/outbound-form-modal.test.tsx | 28 +- .../src/test/outbound-link-parser.test.ts | 452 +- frontend/src/test/outbound-tag-rename.test.ts | 9 +- .../test/outbounds-loopback-index.test.tsx | 4 +- .../src/test/protocol-capabilities.test.ts | 38 +- frontend/src/test/protocols.test.ts | 16 +- .../src/test/remark-template-field.test.tsx | 4 +- frontend/src/test/remark-variables.test.ts | 4 +- frontend/src/test/rhf-form-field.test.tsx | 4 +- .../src/test/routing-default-outbound.test.ts | 9 +- .../src/test/routing-loopback-index.test.tsx | 4 +- .../test/routing-reference-cleanup.test.ts | 70 +- frontend/src/test/rule.test.ts | 13 +- frontend/src/test/security.test.ts | 13 +- frontend/src/test/setting-sub-updates.test.ts | 5 +- frontend/src/test/setup.components.ts | 27 +- frontend/src/test/sockopt.test.ts | 13 +- frontend/src/test/storybook-theme.test.tsx | 10 +- .../src/test/stream-wire-normalize.test.ts | 250 +- frontend/src/test/stream.test.ts | 19 +- .../test/subscription-general-tab.test.tsx | 17 +- frontend/src/test/test-utils.tsx | 9 +- frontend/src/test/use-all-settings.test.tsx | 23 +- frontend/src/test/useServerDraft.test.tsx | 42 +- frontend/src/test/vless-encryption.test.ts | 48 +- frontend/src/test/warp-change-ip.test.ts | 21 +- .../src/test/wireguard-client-config.test.ts | 49 +- .../src/test/wireguard-clients-link.test.ts | 6 +- frontend/src/test/zodValidate.test.ts | 26 +- frontend/src/utils/index.ts | 150 +- .../oxlint/__fixtures__/guard.oxlintrc.json | 6 + frontend/tools/oxlint/__fixtures__/ok.tsx | 7 + .../oxlint/__fixtures__/synthetic-clear.tsx | 11 + frontend/tools/oxlint/input-number-guard.mjs | 57 + 388 files changed, 19613 insertions(+), 14133 deletions(-) create mode 100644 docs/.oxfmtrc.json create mode 100644 docs/.oxlintrc.json delete mode 100644 docs/.prettierignore delete mode 100644 docs/.prettierrc.json delete mode 100644 docs/eslint.config.mjs create mode 100644 frontend/.oxfmtrc.json create mode 100644 frontend/.oxlintrc.json delete mode 100644 frontend/eslint.config.js delete mode 100644 frontend/eslint.deprecated.config.js create mode 100644 frontend/src/test/input-number-guard.test.ts create mode 100644 frontend/tools/oxlint/__fixtures__/guard.oxlintrc.json create mode 100644 frontend/tools/oxlint/__fixtures__/ok.tsx create mode 100644 frontend/tools/oxlint/__fixtures__/synthetic-clear.tsx create mode 100644 frontend/tools/oxlint/input-number-guard.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38659bc5b..f436d5053 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: - "go.sum" - "frontend/**" - ".nvmrc" + - "Makefile" - ".github/workflows/ci.yml" push: branches: @@ -18,6 +19,7 @@ on: - "go.sum" - "frontend/**" - ".nvmrc" + - "Makefile" - ".github/workflows/ci.yml" permissions: @@ -188,6 +190,9 @@ jobs: - name: Lint run: npm run lint working-directory: frontend + - name: Format check + run: npm run format:check + working-directory: frontend - name: Typecheck run: npm run typecheck working-directory: frontend diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index 36ea16162..2d4c2c20b 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -42,6 +42,9 @@ jobs: - name: Lint run: pnpm lint + - name: Format check + run: pnpm format:check + - name: Test run: pnpm test diff --git a/CLAUDE.md b/CLAUDE.md index c8431ded2..f361f78f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ file locations when it can answer in one hop. ## Frontend conventions (summary; full version in frontend/CLAUDE.md) - Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites. -- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in +- TS strict; oxlint's `typescript/no-explicit-any` is an error. Zod schemas in `src/schemas/` are the source of truth; infer types with `z.infer`, never hand-write. Do not edit `src/generated/`. - Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type @@ -147,7 +147,8 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every `make` Go target already depends on it, which is why `make test-go` beats `go test ./...`. Run `make help` for all targets. The local gate: - make verify # gen-check + lint + typecheck + test + build + build-storybook + make verify # gen-check + lint + format-check + typecheck + test + build + # + build-storybook That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`, `make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94fd38b97..79769bc05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -186,7 +186,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit - **Function components + hooks** everywhere. No class components. - **Comments in committed Go/TS/TSX: 2 lines MAX per comment block**, spent on the *why* a name cannot hold — an invariant, an issue number, a non-obvious constraint. Names should carry the meaning; rename rather than annotate. Compiler and tool directives (`//go:build`, `//go:generate`, `//nolint:`) are exempt, and HTML `` is fine for template structure. - **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.) -- **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules. +- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules. - **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`). - **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both. - **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward. @@ -200,7 +200,8 @@ frontend/ ├── login.html — login + 2FA entry ├── subpage.html — public subscription viewer entry ├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*" -├── eslint.config.js — ESLint flat config (@eslint/js + typescript-eslint + react-hooks) +├── .oxlintrc.json — oxlint config (typescript + react-hooks + jsx-a11y) +├── tools/oxlint/ — input-number-guard.mjs (#6121/#6127 guard as a JS plugin) ├── vite.config.js ├── vitest.config.ts ├── scripts/ — build-openapi.mjs (endpoints.ts → openapi.json) @@ -279,7 +280,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml ### CI -`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`/`build-storybook`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green. +`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`format:check`/`test`/`build`/`build-storybook`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green. ## Sending a pull request @@ -288,7 +289,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml 3. Run the relevant checks before pushing: - `go build ./...` - `go test ./...` (when Go code changed) - - `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build && npm run build-storybook` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`) + - `cd frontend && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run build-storybook` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`) 4. Commit messages follow the existing pattern in `git log` — `: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged. 5. Open the PR against `main` with a brief description of what changed and how to test it. diff --git a/Makefile b/Makefile index 07b5d97a1..c177f8412 100644 --- a/Makefile +++ b/Makefile @@ -31,12 +31,16 @@ lint-go: dist-stub ## golangci-lint on Go sources golangci-lint run .PHONY: lint-fe -lint-fe: ## ESLint on frontend sources +lint-fe: ## oxlint on frontend sources cd $(FRONTEND) && npm run lint .PHONY: lint lint: lint-go lint-fe ## All linters +.PHONY: format-check +format-check: ## oxfmt in check mode on frontend sources + cd $(FRONTEND) && npm run format:check + .PHONY: typecheck typecheck: ## tsc --noEmit cd $(FRONTEND) && npm run typecheck @@ -76,8 +80,8 @@ build: build-fe ## Build the frontend then the Go binary build-storybook: ## Build the static Storybook (compile-checks all stories) cd $(FRONTEND) && npm run build-storybook -# The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck, -# both test suites, a full build, and the Storybook compile-check. +# The PR gate. Matches ci.yml: codegen freshness, both linters, the formatter, +# typecheck, both test suites, a full build, and the Storybook compile-check. .PHONY: verify -verify: gen-check lint typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI) +verify: gen-check lint format-check typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI) @echo "verify: OK" diff --git a/docs/.oxfmtrc.json b/docs/.oxfmtrc.json new file mode 100644 index 000000000..f6e659041 --- /dev/null +++ b/docs/.oxfmtrc.json @@ -0,0 +1,21 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "ignorePatterns": [ + "node_modules", + ".next", + ".source", + "out", + "pnpm-lock.yaml", + "public/openapi.json", + // Reflowing MDX prose merges headings into paragraphs and collapses lists + // inside JSX components (Steps/Callout). Author MDX by hand. + "content/**/*.mdx", + // Generated API reference pages (fumadocs-openapi output). + "content/docs/**/reference/api" + ] +} diff --git a/docs/.oxlintrc.json b/docs/.oxlintrc.json new file mode 100644 index 000000000..bab0ed8e7 --- /dev/null +++ b/docs/.oxlintrc.json @@ -0,0 +1,42 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": [ + ".next/**", + ".source/**", + "out/**", + "node_modules/**", + "next-env.d.ts", + "content/docs/**/reference/api/**" + ], + "plugins": ["typescript", "react", "nextjs", "jsx-a11y", "import"], + "categories": { + "correctness": "error" + }, + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "rules": { + "no-var": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "typescript/no-explicit-any": "error", + "typescript/no-unused-vars": "warn", + "typescript/ban-ts-comment": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-namespace": "error", + "typescript/no-require-imports": "error", + "typescript/no-this-alias": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-unused-expressions": "warn", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/triple-slash-reference": "error", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "import/no-anonymous-default-export": "warn", + "jsx-a11y/prefer-tag-over-role": "off" + } +} diff --git a/docs/.prettierignore b/docs/.prettierignore deleted file mode 100644 index dc38002d5..000000000 --- a/docs/.prettierignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules -.next -.source -out -pnpm-lock.yaml -public/openapi.json -# Don't let Prettier reflow MDX prose — it merges headings into paragraphs and -# collapses lists inside JSX components (Steps/Callout). Author MDX by hand. -content/**/*.mdx -content/docs/**/reference/api diff --git a/docs/.prettierrc.json b/docs/.prettierrc.json deleted file mode 100644 index 4cbc711cd..000000000 --- a/docs/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 2 -} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 342b58e1f..5992d27a0 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -20,12 +20,12 @@ pnpm dev # http://localhost:3000 | `pnpm build` | Production build | | `pnpm start` | Serve the production build | | `pnpm typecheck` | Generate MDX/route types and run `tsc --noEmit` | -| `pnpm lint` | ESLint (flat config) | -| `pnpm format` | Format with Prettier | +| `pnpm lint` | oxlint (`.oxlintrc.json`) | +| `pnpm format` | Format with oxfmt (`.oxfmtrc.json`) | | `pnpm test` | Run unit tests (Vitest) for `lib/xray/*` pure logic | | `pnpm gen:api` | Generate the API reference from `public/openapi.json` | -Before opening a pull request, please run `pnpm typecheck`, `pnpm lint`, and +Before opening a pull request, please run `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm test` — these are the same checks that CI runs on every PR. ## License diff --git a/docs/README.md b/docs/README.md index 536da19ab..d73d2457d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,15 +63,15 @@ ever leaves your browser**: ## Tech stack -| Layer | Technology | -| ---------- | ---------------------------------------------------------- | -| Framework | [Next.js 16](https://nextjs.org) (App Router) · React 19 | -| Docs | [Fumadocs](https://fumadocs.dev) (`-ui` / `-core` / `-mdx`) | -| Styling | [Tailwind CSS v4](https://tailwindcss.com) | -| Search | [Orama](https://orama.com) static index | -| Language | TypeScript (strict) | -| Tests | [Vitest](https://vitest.dev) for the pure `lib/xray` logic | -| Tooling | pnpm · ESLint 9 · Prettier | +| Layer | Technology | +| --------- | ----------------------------------------------------------- | +| Framework | [Next.js 16](https://nextjs.org) (App Router) · React 19 | +| Docs | [Fumadocs](https://fumadocs.dev) (`-ui` / `-core` / `-mdx`) | +| Styling | [Tailwind CSS v4](https://tailwindcss.com) | +| Search | [Orama](https://orama.com) static index | +| Language | TypeScript (strict) | +| Tests | [Vitest](https://vitest.dev) for the pure `lib/xray` logic | +| Tooling | pnpm · oxlint · oxfmt | ## Quick start @@ -86,13 +86,13 @@ pnpm dev # http://localhost:3000 Useful scripts: -| Script | Description | -| ---------------- | -------------------------------------------- | -| `pnpm dev` | Start the dev server | -| `pnpm build` | Production build (also typechecks) | -| `pnpm typecheck` | Generate MDX/route types and `tsc --noEmit` | -| `pnpm lint` | Run ESLint | -| `pnpm test` | Run unit tests (Vitest) | +| Script | Description | +| ---------------- | ------------------------------------------- | +| `pnpm dev` | Start the dev server | +| `pnpm build` | Production build (also typechecks) | +| `pnpm typecheck` | Generate MDX/route types and `tsc --noEmit` | +| `pnpm lint` | Run oxlint (`.oxlintrc.json`) | +| `pnpm test` | Run unit tests (Vitest) | See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full list and project conventions. diff --git a/docs/architecture.md b/docs/architecture.md index a9f63ee3f..e9eaf7302 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,17 +29,17 @@ token), with a process restart as the fallback on older binaries. Servers and processes, all launched from `main.go`: -| Server / process | Package | Purpose | Default port | -|---|---|---|---| -| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 | -| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting | -| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` | -| **mtg-multi** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret) | per inbound | +| Server / process | Package | Purpose | Default port | +| ---------------- | --------------------------------- | ------------------------------------------------------------------ | ----------------- | +| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 | +| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting | +| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` | +| **mtg-multi** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret) | per inbound | Two key ideas that explain most of the complexity: 1. **The DB → Xray config pipeline.** Inbounds/clients live in the DB. On every change the - backend regenerates the Xray config and applies it — preferring a *hot diff* (live gRPC + backend regenerates the Xray config and applies it — preferring a _hot diff_ (live gRPC API mutation) over a full process restart. See §5.1. 2. **The Runtime abstraction (multi-node).** A panel can manage remote "nodes" (other 3x-ui instances). Every state-changing inbound/client operation is dispatched through a @@ -52,6 +52,7 @@ Two key ideas that explain most of the complexity: ## 2. Tech stack **Backend (Go 1.26):** + - Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip. - ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`). - Scheduler: **robfig/cron/v3** (seconds-precision) for all background jobs. @@ -61,6 +62,7 @@ Two key ideas that explain most of the complexity: - Misc: gorilla/websocket, gopsutil (system stats), go-qrcode, gotp (2FA TOTP). **Frontend (`frontend/`):** + - **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**. - Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas. - Router: **react-router 8**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**. @@ -95,7 +97,7 @@ Browser (React, fetch) ``` The controller layer is thin. **Business logic lives in services.** When something is wrong -with *behavior*, the bug is almost always in a service file, not a controller. +with _behavior_, the bug is almost always in a service file, not a controller. ### 3.2 Subscription request (end-user fetching their config) @@ -312,8 +314,8 @@ Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` / ### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing -inbound/client operation goes through the `runtime.Runtime` interface so the *same service -code* works whether the target is the local Xray or a remote node. +inbound/client operation goes through the `runtime.Runtime` interface so the _same service +code_ works whether the target is the local Xray or a remote node. - **Interface:** `internal/web/runtime/runtime.go` — `Name`, `AddInbound`, `DelInbound`, `UpdateInbound`, `AddUser`, `RemoveUser`, `UpdateUser`, `DeleteUser`, `AddClient`, @@ -329,7 +331,7 @@ code* works whether the target is the local Xray or a remote node. - **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`, otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client. -**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` *and* an +**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` _and_ an `OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and online clients back to the originating panel using **stable GUIDs** rather than local IDs. Relevant logic: `service/inbound_node.go` (`ReconcileNode`, `SetRemoteTraffic`, GUID merge, @@ -338,6 +340,7 @@ tracking). Node "dirty" flags drive an **anti-entropy reconciliation** so an off inbound edits converge once it reconnects. **Where to look for node bugs:** + - Operation not reaching a node → `runtime/remote.go` + `runtime/manager.go`. - Wrong traffic/online attribution across hops → `service/inbound_node.go` (GUID merge paths). - Node shown offline / stale status → `job/node_heartbeat_job.go` + `service/node.go` (`Probe`, `UpdateHeartbeat`). @@ -360,28 +363,28 @@ Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.Traffic All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`: -| Schedule | Job | Purpose / condition | -|---|---|---| -| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) | -| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) | -| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) | -| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) | -| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation | -| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits | -| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds | -| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs | -| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB | -| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets | -| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets | -| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets | -| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable | -| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable | -| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes | -| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` | -| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` | -| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS | +| Schedule | Job | Purpose / condition | +| ------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) | +| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) | +| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) | +| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) | +| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation | +| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits | +| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds | +| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs | +| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB | +| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets | +| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets | +| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets | +| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable | +| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable | +| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes | +| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` | +| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` | +| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS | -To change *when* something runs, edit `startTask()`. To change *what* it does, edit the job file. +To change _when_ something runs, edit `startTask()`. To change _what_ it does, edit the job file. ### 5.5 Type generation (Go → TypeScript) ⚠️ don't hand-edit generated files @@ -400,8 +403,9 @@ frontend types (`cd frontend && npm run gen`) instead of editing `src/generated/ ### 5.6 Share-link / subscription generation Two distinct code paths produce client configs: + - **Per-client links in the panel** (the "copy link" / QR in the UI): `service/client_link.go` - + `util/link/outbound.go`. + - `util/link/outbound.go`. - **Subscription endpoint** (what a client app polls): `internal/sub/service.go` (raw links), `internal/sub/json_service.go` (JSON), `internal/sub/clash_service.go` (Clash YAML). **`Host` rows** (`model.Host`, edited under /panel/api/hosts) override address/SNI/path/ @@ -438,70 +442,70 @@ Xray restart. GORM models in `internal/database/model/` (main file `model.go` + siblings); all registered for AutoMigrate in `internal/database/db.go`. -| Model | Table role | Notable fields | -|---|---|---| -| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) | -| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) | -| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) | -| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` | -| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` | -| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` | -| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags | -| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields | -| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) | -| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` | -| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` | -| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` | -| `InboundClientIps` | IP set per client email | drives IP-limit enforcement | -| `OutboundTraffics` | Outbound counters | per outbound tag | -| `OutboundSubscription` | External provider subs | Warp/Nord style | -| `Setting` | Key/value panel settings | everything configurable | -| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) | -| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest | -| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations | +| Model | Table role | Notable fields | +| ------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) | +| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) | +| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) | +| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` | +| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` | +| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` | +| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags | +| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields | +| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) | +| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` | +| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` | +| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` | +| `InboundClientIps` | IP set per client email | drives IP-limit enforcement | +| `OutboundTraffics` | Outbound counters | per outbound tag | +| `OutboundSubscription` | External provider subs | Warp/Nord style | +| `Setting` | Key/value panel settings | everything configurable | +| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) | +| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest | +| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations | --- ## 7. Symptom → File index (start here when debugging) -| Symptom / task | Primary file(s) | Then check | -|---|---|---| -| Add/modify an **API endpoint** | `controller/.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` | -| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` | -| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` | -| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` | -| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` | -| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` | -| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` | -| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` | -| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` | -| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) | -| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) | -| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) | -| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` | -| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests | -| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` | -| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` | -| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` | -| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` | -| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` | -| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` | -| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` | -| **Geo category browser** empty / won't open | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go` | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` | -| **`geosite:`/`geoip:` token** reported unknown in a routing rule | `xray/geodata/token.go`, `service/geodata.go` (`Validate`) | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/` | -| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` | -| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) | -| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` | -| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` | -| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` | -| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` | -| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` | -| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` | -| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) | -| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` | -| **Frontend route / screen** | `frontend/src/pages//`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` | -| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` | -| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil | +| Symptom / task | Primary file(s) | Then check | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Add/modify an **API endpoint** | `controller/.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` | +| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` | +| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` | +| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` | +| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` | +| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` | +| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` | +| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` | +| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` | +| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) | +| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) | +| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) | +| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` | +| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests | +| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` | +| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` | +| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` | +| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` | +| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` | +| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` | +| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` | +| **Geo category browser** empty / won't open | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go` | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` | +| **`geosite:`/`geoip:` token** reported unknown in a routing rule | `xray/geodata/token.go`, `service/geodata.go` (`Validate`) | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/` | +| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` | +| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) | +| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` | +| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` | +| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` | +| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` | +| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` | +| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` | +| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) | +| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` | +| **Frontend route / screen** | `frontend/src/pages//`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` | +| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` | +| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil | --- @@ -522,7 +526,7 @@ for AutoMigrate in `internal/database/db.go`. Regenerate instead. 7. **Models are the contract.** Changing a model field that crosses the API boundary means: update `model.go` → handle migration in `db.go`/`migrate_data.go` → regenerate frontend types. -8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an *end user* +8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an _end user_ fetches goes in `internal/sub`. Don't blur them. 9. **Cross-cutting notifications go through `internal/eventbus/`** — publish an event instead of importing the Telegram/email services into producers. @@ -536,6 +540,7 @@ The canonical gate is the **Makefile** (mirrors CI): `make verify`. Also: `make frontend), `make race`, `make build`. Run `make help` for everything. Raw commands: **Backend (Go):** + ```bash go build ./... # compile everything go test ./... # run all Go tests (many *_test.go alongside sources) @@ -548,11 +553,12 @@ go run main.go # run the panel locally (serves embedded dis ``` **Frontend (`cd frontend`, Node 24 — see `.nvmrc`):** + ```bash npm install npm run dev # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too) npm run typecheck # tsc --noEmit -npm run lint # eslint src +npm run lint # oxlint src npm run test # vitest (incl. golden config-generation snapshots) npm run gen # regenerate src/generated/* from Go (gen:zod + gen:api) npm run build # gen:api + vite build → outputs to internal/web/dist (then rebuild Go binary to embed) diff --git a/docs/components/tools/api-request-builder.tsx b/docs/components/tools/api-request-builder.tsx index de0b878f6..4f4b567dd 100644 --- a/docs/components/tools/api-request-builder.tsx +++ b/docs/components/tools/api-request-builder.tsx @@ -1,7 +1,12 @@ 'use client'; import { useId, useState } from 'react'; -import { buildCurl, buildFetchSnippet, type ApiRequestInput, type HttpMethod } from '@/lib/xray/api-client'; +import { + buildCurl, + buildFetchSnippet, + type ApiRequestInput, + type HttpMethod, +} from '@/lib/xray/api-client'; import { ToolFrame } from './tool-frame'; import { TextField, SelectField } from './shared/fields'; import { OutputBlock } from './shared/output-block'; diff --git a/docs/components/tools/routing-builder.tsx b/docs/components/tools/routing-builder.tsx index deee35e0e..75c55a8ce 100644 --- a/docs/components/tools/routing-builder.tsx +++ b/docs/components/tools/routing-builder.tsx @@ -38,8 +38,24 @@ const DEFAULT_BALANCERS: BalancerRow[] = [ { tag: 'balancer', selector: 'proxy', strategy: 'leastPing', fallbackTag: '' }, ]; const DEFAULT_RULES: RuleRow[] = [ - { domain: 'geosite:category-ads-all', ip: '', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: 'block' }, - { domain: '', ip: 'geoip:private', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: 'direct' }, + { + domain: 'geosite:category-ads-all', + ip: '', + port: '', + network: 'any', + inboundTag: '', + targetKind: 'outbound', + targetTag: 'block', + }, + { + domain: '', + ip: 'geoip:private', + port: '', + network: 'any', + inboundTag: '', + targetKind: 'outbound', + targetTag: 'direct', + }, ]; function list(s: string): string[] { @@ -113,7 +129,10 @@ export function RoutingBuilder() { type="button" className={addBtn} onClick={() => - setBalancers((p) => [...p, { tag: '', selector: '', strategy: 'random', fallbackTag: '' }]) + setBalancers((p) => [ + ...p, + { tag: '', selector: '', strategy: 'random', fallbackTag: '' }, + ]) } > Add balancer @@ -163,7 +182,15 @@ export function RoutingBuilder() { onClick={() => setRules((p) => [ ...p, - { domain: '', ip: '', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: '' }, + { + domain: '', + ip: '', + port: '', + network: 'any', + inboundTag: '', + targetKind: 'outbound', + targetTag: '', + }, ]) } > @@ -174,13 +201,47 @@ export function RoutingBuilder() { {rules.map((r, i) => (
- patchRule(i, { domain: v })} placeholder="geosite:google, example.com" /> - patchRule(i, { ip: v })} placeholder="geoip:cn, 1.1.1.1" /> - patchRule(i, { port: v })} placeholder="443 or 1000-2000" /> - patchRule(i, { network: v })} options={NETWORKS} /> - patchRule(i, { inboundTag: v })} placeholder="optional" /> - patchRule(i, { targetKind: v as 'outbound' | 'balancer' })} options={TARGET_KINDS} /> - patchRule(i, { targetTag: v })} /> + patchRule(i, { domain: v })} + placeholder="geosite:google, example.com" + /> + patchRule(i, { ip: v })} + placeholder="geoip:cn, 1.1.1.1" + /> + patchRule(i, { port: v })} + placeholder="443 or 1000-2000" + /> + patchRule(i, { network: v })} + options={NETWORKS} + /> + patchRule(i, { inboundTag: v })} + placeholder="optional" + /> + patchRule(i, { targetKind: v as 'outbound' | 'balancer' })} + options={TARGET_KINDS} + /> + patchRule(i, { targetTag: v })} + />
+
Last confirmed: {value || '—'}
) : type === 'textarea' ? ( { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }} + ref={(el) => { + textareaRef.current = + (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } }) + ?.resizableTextArea?.textArea ?? null; + }} aria-label={title} value={value} onChange={(e) => setValue(e.target.value)} diff --git a/frontend/src/components/feedback/TextModal.stories.tsx b/frontend/src/components/feedback/TextModal.stories.tsx index c37f90430..bf5a99c43 100644 --- a/frontend/src/components/feedback/TextModal.stories.tsx +++ b/frontend/src/components/feedback/TextModal.stories.tsx @@ -21,8 +21,13 @@ const meta = { open: { description: 'Whether the modal is visible.' }, title: { description: 'Modal title text.' }, content: { description: 'Text shown when no `tabs` are provided.' }, - fileName: { description: 'When set, adds a download button that saves the active content under this name.' }, - json: { description: 'Render the content in a read-only JSON editor with syntax highlighting.' }, + fileName: { + description: + 'When set, adds a download button that saves the active content under this name.', + }, + json: { + description: 'Render the content in a read-only JSON editor with syntax highlighting.', + }, tabs: { description: 'Optional list of `{ key, label, content }` documents shown as tabs.' }, onClose: { description: 'Called when the modal is dismissed.' }, }, diff --git a/frontend/src/components/feedback/TextModal.tsx b/frontend/src/components/feedback/TextModal.tsx index fcb73b218..f58a42d35 100644 --- a/frontend/src/components/feedback/TextModal.tsx +++ b/frontend/src/components/feedback/TextModal.tsx @@ -22,7 +22,15 @@ interface TextModalProps { tabs?: TextModalTab[]; } -export default function TextModal({ open, onClose, title, content, fileName = '', json = false, tabs }: TextModalProps) { +export default function TextModal({ + open, + onClose, + title, + content, + fileName = '', + json = false, + tabs, +}: TextModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const [activeKey, setActiveKey] = useState(''); @@ -55,37 +63,41 @@ export default function TextModal({ open, onClose, title, content, fileName = '' title={title} onCancel={onClose} destroyOnHidden - footer={( - <> - {fileName && ( - - )} - - - )} - > - {tabs && tabs.length > 0 && ( - ({ key: tab.key, label: tab.label }))} - /> - )} - {json ? ( - - ) : ( - - )} + footer={ + <> + {fileName && ( + + )} + + + } + > + {tabs && tabs.length > 0 && ( + ({ key: tab.key, label: tab.label }))} + /> + )} + {json ? ( + + ) : ( + + )} ); diff --git a/frontend/src/components/form/DateTimePicker.css b/frontend/src/components/form/DateTimePicker.css index b8f289f9b..52756f796 100644 --- a/frontend/src/components/form/DateTimePicker.css +++ b/frontend/src/components/form/DateTimePicker.css @@ -27,7 +27,7 @@ .jdp-dark input::placeholder, .jdp-ultra input::placeholder { - color: rgba(255, 255, 255, 0.30) !important; + color: rgba(255, 255, 255, 0.3) !important; } .jdp-disabled { @@ -62,7 +62,7 @@ } .jdp-dark .jdp-clear { - color: rgba(255, 255, 255, 0.30); + color: rgba(255, 255, 255, 0.3); } .jdp-dark .jdp-clear:hover, diff --git a/frontend/src/components/form/DateTimePicker.stories.tsx b/frontend/src/components/form/DateTimePicker.stories.tsx index 9349384ad..7ca22ef5e 100644 --- a/frontend/src/components/form/DateTimePicker.stories.tsx +++ b/frontend/src/components/form/DateTimePicker.stories.tsx @@ -17,7 +17,9 @@ function ClientExpiryDemo() {
- {value ? `user1@node-de expiryTime: ${value.valueOf()}` : 'user1@node-de expiryTime: 0 (never expires)'} + {value + ? `user1@node-de expiryTime: ${value.valueOf()}` + : 'user1@node-de expiryTime: 0 (never expires)'}
); diff --git a/frontend/src/components/form/DateTimePicker.tsx b/frontend/src/components/form/DateTimePicker.tsx index 0e955ed61..805147b76 100644 --- a/frontend/src/components/form/DateTimePicker.tsx +++ b/frontend/src/components/form/DateTimePicker.tsx @@ -90,7 +90,10 @@ export default function DateTimePicker({ if (datepicker === 'jalalian') { return ( -
+
; @@ -63,7 +71,14 @@ function WireShapeDemo() { return (
-
+      
         {JSON.stringify(value ?? {}, null, 2)}
       
diff --git a/frontend/src/components/form/HeaderMapEditor.tsx b/frontend/src/components/form/HeaderMapEditor.tsx index 41a4973fc..76c3c8607 100644 --- a/frontend/src/components/form/HeaderMapEditor.tsx +++ b/frontend/src/components/form/HeaderMapEditor.tsx @@ -24,10 +24,7 @@ import { InputAddon } from '@/components/ui'; export type HeaderMapMode = 'v1' | 'v2'; -export type HeaderMapValue = - | Record - | Record - | undefined; +export type HeaderMapValue = Record | Record | undefined; interface HeaderRow { name: string; @@ -55,7 +52,10 @@ function mapToRows(value: HeaderMapValue): HeaderRow[] { return out; } -function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record | Record { +function rowsToMap( + rows: HeaderRow[], + mode: HeaderMapMode, +): Record | Record { if (mode === 'v1') { const map: Record = {}; for (const r of rows) { @@ -132,7 +132,11 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit placeholder="Value" onChange={(e) => setRow(idx, { value: e.target.value })} /> -
diff --git a/frontend/src/components/form/RemarkVarPicker.stories.tsx b/frontend/src/components/form/RemarkVarPicker.stories.tsx index ffc6f49e0..8ead10795 100644 --- a/frontend/src/components/form/RemarkVarPicker.stories.tsx +++ b/frontend/src/components/form/RemarkVarPicker.stories.tsx @@ -20,7 +20,10 @@ const meta = { }, }, argTypes: { - onPick: { description: 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.' }, + onPick: { + description: + 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.', + }, }, } satisfies Meta; @@ -29,7 +32,9 @@ export default meta; type Story = StoryObj; function TemplateBuilderDemo() { - const [template, setTemplate] = useState('{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left'); + const [template, setTemplate] = useState( + '{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left', + ); return (
{t('pages.hosts.remarkVars.intro')} - {REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => ( -
-
- {t(`pages.hosts.remarkVars.groups.${group}`)} + {REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map( + (group) => ( +
+
+ {t(`pages.hosts.remarkVars.groups.${group}`)} +
+
+ {variables + .filter((v) => v.group === group) + .map((v) => ( + + onPick(v.token)} + onKeyDown={activateOnKey(() => onPick(v.token))} + style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }} + > + {wrapToken(v.token)} + + + ))} +
-
- {variables.filter((v) => v.group === group).map((v) => ( - - onPick(v.token)} - onKeyDown={activateOnKey(() => onPick(v.token))} - style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }} - > - {wrapToken(v.token)} - - - ))} -
-
- ))} + ), + )}
); } diff --git a/frontend/src/components/form/SelectAllClearButtons.stories.tsx b/frontend/src/components/form/SelectAllClearButtons.stories.tsx index df999564e..d2f695e72 100644 --- a/frontend/src/components/form/SelectAllClearButtons.stories.tsx +++ b/frontend/src/components/form/SelectAllClearButtons.stories.tsx @@ -33,11 +33,23 @@ const meta = { }, }, argTypes: { - options: { description: 'Option list whose values define the "all" set; matches the AntD Select option shape.' }, + options: { + description: + 'Option list whose values define the "all" set; matches the AntD Select option shape.', + }, value: { description: 'Currently selected values (controlled).' }, - onChange: { description: 'Called with the union of the current selection and every option value, or with an empty array on clear.' }, - selectAllLabel: { description: 'Override for the "Select all" button text; defaults to the translated inbound copy.' }, - clearLabel: { description: 'Override for the "Clear all" button text; defaults to the translated inbound copy.' }, + onChange: { + description: + 'Called with the union of the current selection and every option value, or with an empty array on clear.', + }, + selectAllLabel: { + description: + 'Override for the "Select all" button text; defaults to the translated inbound copy.', + }, + clearLabel: { + description: + 'Override for the "Clear all" button text; defaults to the translated inbound copy.', + }, }, } satisfies Meta; diff --git a/frontend/src/components/form/SelectAllClearButtons.tsx b/frontend/src/components/form/SelectAllClearButtons.tsx index a5a46a19b..e6e25864b 100644 --- a/frontend/src/components/form/SelectAllClearButtons.tsx +++ b/frontend/src/components/form/SelectAllClearButtons.tsx @@ -35,11 +35,7 @@ export default function SelectAllClearButtons {selectAllLabel ?? t('pages.clients.selectAllInbounds')} -
diff --git a/frontend/src/components/form/rhf/FormField.stories.tsx b/frontend/src/components/form/rhf/FormField.stories.tsx index c69f01876..3c010267c 100644 --- a/frontend/src/components/form/rhf/FormField.stories.tsx +++ b/frontend/src/components/form/rhf/FormField.stories.tsx @@ -25,14 +25,24 @@ const meta = { }, argTypes: { name: { description: 'Field path — a dotted string or an array of segments joined with dots.' }, - control: { description: 'Optional react-hook-form control; falls back to the surrounding FormProvider.' }, + control: { + description: 'Optional react-hook-form control; falls back to the surrounding FormProvider.', + }, label: { description: 'Form.Item label.' }, tooltip: { description: 'Form.Item tooltip shown next to the label.' }, extra: { description: 'Helper text rendered below the input.' }, - valueProp: { description: 'Prop the child receives the value on: `value` (default) or `checked` for switches.' }, - transform: { description: 'Optional input/output mappers, e.g. bytes stored in the form but GB shown in the input.' }, + valueProp: { + description: + 'Prop the child receives the value on: `value` (default) or `checked` for switches.', + }, + transform: { + description: + 'Optional input/output mappers, e.g. bytes stored in the form but GB shown in the input.', + }, onAfterChange: { description: 'Called with the stored value after every change.' }, - rules: { description: 'Controller-level validation rules applied on top of the form resolver.' }, + rules: { + description: 'Controller-level validation rules applied on top of the form resolver.', + }, required: { description: 'Marks the label with the required asterisk.' }, noStyle: { description: 'Render the bare input without Form.Item chrome.' }, children: { description: 'The single Ant Design control to wire up.' }, @@ -56,7 +66,12 @@ function ClientDemo() { return (
- + @@ -96,7 +111,9 @@ function TrafficDemo() { > - Form state: {totalBytes.toLocaleString()} bytes + + Form state: {totalBytes.toLocaleString()} bytes +
); diff --git a/frontend/src/components/form/rhf/useZodForm.ts b/frontend/src/components/form/rhf/useZodForm.ts index c8f175824..62546c786 100644 --- a/frontend/src/components/form/rhf/useZodForm.ts +++ b/frontend/src/components/form/rhf/useZodForm.ts @@ -7,7 +7,9 @@ export function useZodForm( schema: z.ZodType, options?: Omit, 'resolver'>, ): UseFormReturn { - const resolver = zodResolver(schema as z.ZodType) as Resolver; + const resolver = zodResolver( + schema as z.ZodType, + ) as Resolver; return useForm({ mode: 'onSubmit', reValidateMode: 'onChange', diff --git a/frontend/src/components/geodata/GeoBrowserModal.stories.tsx b/frontend/src/components/geodata/GeoBrowserModal.stories.tsx index aeba7cfde..502cfb2f4 100644 --- a/frontend/src/components/geodata/GeoBrowserModal.stories.tsx +++ b/frontend/src/components/geodata/GeoBrowserModal.stories.tsx @@ -42,7 +42,9 @@ function deactivate(routes: GeoRoutes): void { function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) { const [client] = useState(() => { activate(routes); - return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); }); useEffect(() => { activate(routes); @@ -61,121 +63,379 @@ 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', + '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', + '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', + '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 = { 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'), + 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'), + 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'), + 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']), + 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'), + 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'), + 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'), + 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'), + 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'), + 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'), + 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}`)), + 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'), + 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'), + 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'), + 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('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'), + 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'), + 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'), + 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('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'), + domain('youtube.com'), + domain('youtu.be'), + domain('ytimg.com'), + domain('googlevideo.com'), + domain('youtube-nocookie.com'), + domain('yt.be'), ], }; @@ -192,68 +452,206 @@ const SITE_ATTRIBUTES: Record = { }; 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', + '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 CN_EXTRA_BLOCKS = Array.from( + { length: 96 }, + (_, index) => `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`, ); const IP_ENTRIES: Record = { 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', + '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', + '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', + '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', + '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', + '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', + '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', + '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', + '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', + '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), }; @@ -305,10 +703,11 @@ const OVERSIZED_FILE: GeoFile = { error: 'geodata file is too large to browse', }; -const DATASETS: Record }> = { - 'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES }, - 'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES }, -}; +const DATASETS: Record }> = + { + 'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES }, + 'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES }, + }; function routesFor(files: GeoFile[]): GeoRoutes { return { @@ -316,7 +715,9 @@ function routesFor(files: GeoFile[]): GeoRoutes { '/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)); + const items = (dataset?.categories ?? []).filter((category) => + category.code.includes(needle), + ); return { total: items.length, items }; }, '/panel/api/xray/geodata/entries': (query) => { @@ -351,7 +752,7 @@ function BrowserDemo(props: GeoBrowserModalProps) { useEffect(() => setOpen(props.open), [props.open]); useEffect(() => setValue(props.value), [props.value]); return ( - + {value || 'no rule yet'} @@ -398,12 +799,14 @@ const meta = { 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.', + 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.', + 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.' }, diff --git a/frontend/src/components/geodata/GeoBrowserModal.tsx b/frontend/src/components/geodata/GeoBrowserModal.tsx index 612c29245..f08b014c0 100644 --- a/frontend/src/components/geodata/GeoBrowserModal.tsx +++ b/frontend/src/components/geodata/GeoBrowserModal.tsx @@ -1,6 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Button, Empty, Input, Modal, Pagination, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'; +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'; @@ -25,7 +38,9 @@ export interface GeoBrowserModalProps { // 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))); + return files.filter( + (file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)), + ); } function namePrefersKind(name: string, kind: GeoKind): boolean { @@ -38,7 +53,13 @@ function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined { return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name; } -export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: GeoBrowserModalProps) { +export default function GeoBrowserModal({ + open, + kind, + value, + onApply, + onClose, +}: GeoBrowserModalProps) { const { t } = useTranslation(); const [file, setFile] = useState(undefined); const [categoryQuery, setCategoryQuery] = useState(''); @@ -120,7 +141,10 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: seededFilesRef.current.add(file); const fromValue = selectionFromValue(value, new Set(tokens)); if (fromValue.length > 0) { - setSelected((previous) => [...previous, ...fromValue.filter((token) => !previous.includes(token))]); + setSelected((previous) => [ + ...previous, + ...fromValue.filter((token) => !previous.includes(token)), + ]); } }, [open, file, categories, fileKind, value]); @@ -149,7 +173,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: // 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))), + visibleCategories.map((category) => + canonicalToken(tokenFor(file, category.code, fileKind)), + ), ); setSelected((previous) => { const kept = previous.filter((token) => { @@ -157,7 +183,10 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: return !shown.has(canonical) || chosenCanonical.has(canonical); }); const keptCanonical = new Set(kept.map(canonicalToken)); - return [...kept, ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token)))]; + return [ + ...kept, + ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token))), + ]; }); }, [visibleCategories, file, fileKind], @@ -174,7 +203,7 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: {category.attributes?.length > 0 && ( {category.attributes.map((attribute) => ( - + @{attribute} ))} @@ -199,7 +228,7 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: dataIndex: 'kind', width: 88, render: (entryKind: string) => ( - + {entryKind} ), @@ -214,7 +243,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: const fileOptions = files.map((candidate) => ({ value: candidate.name, - label: candidate.error ? `${candidate.name} — ${describeFileError(candidate.error, t)}` : candidate.name, + label: candidate.error + ? `${candidate.name} — ${describeFileError(candidate.error, t)}` + : candidate.name, disabled: !!candidate.error, })); @@ -229,9 +260,14 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: 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(), - }) + ? t( + fileKind === 'ip' + ? 'pages.xray.geoBrowser.subnetsCount' + : 'pages.xray.geoBrowser.entriesCount', + { + count: activeCategory.entries.toLocaleString(), + }, + ) : ''; return ( @@ -245,7 +281,14 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: cancelText={t('close')} className="geo-browser-modal" > - {filesQuery.isError && } + {filesQuery.isError && ( + + )} {!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? ( {t('pages.xray.geoBrowser.noFiles')}
- {t('pages.xray.geoBrowser.noFilesHint')} + + {t('pages.xray.geoBrowser.noFilesHint')} +
} /> @@ -279,7 +324,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: allowClear />
@@ -377,7 +431,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
{selected.length === 0 ? ( - {t('pages.xray.geoBrowser.emptySelection')} + + {t('pages.xray.geoBrowser.emptySelection')} + ) : ( <> @@ -386,7 +442,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: key={token} closable color="processing" - onClose={() => setSelected((previous) => previous.filter((item) => item !== token))} + onClose={() => + setSelected((previous) => previous.filter((item) => item !== token)) + } > {token} diff --git a/frontend/src/components/geodata/GeoTokenInput.stories.tsx b/frontend/src/components/geodata/GeoTokenInput.stories.tsx index 522f9298f..6da9b7f00 100644 --- a/frontend/src/components/geodata/GeoTokenInput.stories.tsx +++ b/frontend/src/components/geodata/GeoTokenInput.stories.tsx @@ -44,7 +44,9 @@ function deactivate(routes: GeoRoutes): void { function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) { const [client] = useState(() => { activate(routes); - return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); }); useEffect(() => { activate(routes); @@ -58,25 +60,55 @@ const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value }); const SITE_ENTRIES: Record = { 'category-ads-all': [ - domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'), - domain('criteo.com'), domain('taboola.com'), domain('outbrain.com'), + 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'), ], - 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'), + 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'), ], - 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')], + youtube: [ + domain('youtube.com'), + domain('youtu.be'), + domain('ytimg.com'), + domain('googlevideo.com'), + ], }; const IP_ENTRIES: Record = { 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', + '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), }; @@ -95,10 +127,14 @@ function categoriesOf( .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] })); } -const DATASETS: Record }> = { - 'geosite.dat': { categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), entries: SITE_ENTRIES }, - 'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES }, -}; +const DATASETS: Record }> = + { + '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); @@ -125,7 +161,9 @@ function referenceOf(token: string, isIP: boolean): { file: string; code: string 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; + return isIP && prefix === 'ext-ip' + ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) } + : null; } function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] { @@ -180,7 +218,7 @@ function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoToken const [current, setCurrent] = useState(value); useEffect(() => setCurrent(value), [value]); return ( - + @@ -209,10 +247,15 @@ const meta = { 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.' }, + 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.', + description: + 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.', control: 'inline-radio', options: ['domain', 'ip'], }, @@ -242,6 +285,8 @@ 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(); + await expect( + await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 }), + ).toBeVisible(); }, }; diff --git a/frontend/src/components/geodata/GeoTokenInput.tsx b/frontend/src/components/geodata/GeoTokenInput.tsx index 9cbd4e5dd..a7b0b4b6b 100644 --- a/frontend/src/components/geodata/GeoTokenInput.tsx +++ b/frontend/src/components/geodata/GeoTokenInput.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import type { Ref } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Input, Tooltip, Typography } from 'antd'; +import { Button, Input, Space, Tooltip, Typography } from 'antd'; import type { InputRef } from 'antd'; import { DatabaseOutlined } from '@ant-design/icons'; @@ -33,7 +33,15 @@ export interface GeoTokenInputProps { ref?: Ref; } -export default function GeoTokenInput({ value = '', onChange, onBlur, kind, placeholder, id, ref }: GeoTokenInputProps) { +export default function GeoTokenInput({ + value = '', + onChange, + onBlur, + kind, + placeholder, + id, + ref, +}: GeoTokenInputProps) { const { t } = useTranslation(); const [browsing, setBrowsing] = useState(false); const [issues, setIssues] = useState([]); @@ -72,25 +80,23 @@ export default function GeoTokenInput({ value = '', onChange, onBlur, kind, plac return ( <> - onChange?.(event.target.value)} - onBlur={onBlur} - addonAfter={ - -
))} @@ -644,7 +731,9 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) { } function HeaderCustomGroups({ - tcpFieldName, form, absoluteSettingsPath, + tcpFieldName, + form, + absoluteSettingsPath, }: { tcpFieldName: number; form: FormInstance; @@ -695,7 +784,12 @@ function HeaderCustomGroups({ key={item.key} fieldName={item.name} form={form} - absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]} + absoluteItemPath={[ + ...absoluteSettingsPath, + groupKey, + group.name, + item.name, + ]} delayMode="number" onRemove={() => removeItem(item.name)} /> @@ -714,8 +808,18 @@ function HeaderCustomGroups({ } function UdpMasksList({ - base, form, isHysteria, isWireguard, network, -}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) { + base, + form, + isHysteria, + isWireguard, + network, +}: { + base: (string | number)[]; + form: FormInstance; + isHysteria: boolean; + isWireguard: boolean; + network: string; +}) { const { t } = useTranslation(); return ( @@ -753,7 +857,14 @@ function UdpMasksList({ } function UdpMaskItem({ - fieldName, displayIndex, form, listPath, isHysteria, isWireguard, network, onRemove, + fieldName, + displayIndex, + form, + listPath, + isHysteria, + isWireguard, + network, + onRemove, }: { fieldName: number; displayIndex: number; @@ -778,16 +889,16 @@ function UdpMaskItem({ const options = isHysteria ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] : [ - // Salamander is the mask xray-core's own wireguard finalmask example - // uses; it stays hysteria-only elsewhere to keep legacy parity. - ...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []), - { value: 'mkcp-legacy', label: 'mKCP Legacy' }, - { value: 'xdns', label: 'xDNS' }, - { value: 'xicmp', label: 'xICMP' }, - { value: 'realm', label: 'Realm' }, - { value: 'header-custom', label: 'Header Custom' }, - { value: 'noise', label: 'Noise' }, - ]; + // Salamander is the mask xray-core's own wireguard finalmask example + // uses; it stays hysteria-only elsewhere to keep legacy parity. + ...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []), + { value: 'mkcp-legacy', label: 'mKCP Legacy' }, + { value: 'xdns', label: 'xDNS' }, + { value: 'xicmp', label: 'xICMP' }, + { value: 'realm', label: 'Realm' }, + { value: 'header-custom', label: 'Header Custom' }, + { value: 'noise', label: 'Noise' }, + ]; return (
@@ -809,12 +920,20 @@ function UdpMaskItem({ getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])} + shouldUpdate={(prev, curr) => + getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type']) + } > {({ getFieldValue }) => { const type = getFieldValue([...absolutePath, 'type']) as string | undefined; if (type === 'salamander') { - return ; + return ( + + ); } if (type === 'mkcp-legacy') { return ( @@ -848,7 +967,11 @@ function UdpMaskItem({ if (type === 'xicmp') { return ( <> - + @@ -864,10 +987,20 @@ function UdpMaskItem({ - - TLS (optional) - + + TLS (optional) + + @@ -881,12 +1014,11 @@ function UdpMaskItem({ ]} /> - - methods.setValue('uuid', e.target.value)} /> -
{linkRows.length === 0 ? ( - {t('pages.clients.noExternalLinks')} - ) : linkRows.map(({ field, index }) => ( -
-
-
- - - - {t('enable')} -
- - - - -
-
- - - - ( - 0 ? dayjs(Number(expiryField.value)) : null} - onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} - placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + + {t('pages.clients.noExternalLinks')} + + ) : ( + linkRows.map(({ field, index }) => ( +
+
+
+ + + + {t('enable')} +
+ + - )} - /> + + +
+
+ + + + ( + 0 + ? dayjs(Number(expiryField.value)) + : null + } + onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} + placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + /> + )} + /> +
-
- ))} + )) + )}
-
{subscriptionRows.length === 0 ? ( - {t('pages.clients.noExternalSubscriptions')} - ) : subscriptionRows.map(({ field, index }) => ( -
-
-
- - - - {t('enable')} -
- - - - -
-
- - - - - - - ( - 0 ? dayjs(Number(expiryField.value)) : null} - onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} - placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + + {t('pages.clients.noExternalSubscriptions')} + + ) : ( + subscriptionRows.map(({ field, index }) => ( +
+
+
+ + + + {t('enable')} +
+ + - )} - /> + + +
+
+ + + + + + + ( + 0 + ? dayjs(Number(expiryField.value)) + : null + } + onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} + placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + /> + )} + /> +
+ + {field.lastFetchError + ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}` + : field.lastFetchAt > 0 + ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}` + : t('pages.clients.neverFetched')} +
- - {field.lastFetchError - ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}` - : field.lastFetchAt > 0 - ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}` - : t('pages.clients.neverFetched')} - -
- ))} + )) + )}
), @@ -1180,7 +1387,13 @@ reset: Number(values.reset) || 0, , - , , - , @@ -329,7 +409,11 @@ export default function ClientInfoModal({ {t('pages.clients.renewsUsed')} - = (traffic?.resetMax ?? 0) ? 'red' : 'blue'}> + = (traffic?.resetMax ?? 0) ? 'red' : 'blue' + } + > {traffic?.resetCount ?? 0} / {traffic?.resetMax} @@ -337,22 +421,30 @@ export default function ClientInfoModal({ )} {t('pages.inbounds.createdAt')} - {dateLabel(client.createdAt)} + + {dateLabel(client.createdAt)} + {t('pages.inbounds.updatedAt')} - {dateLabel(client.updatedAt)} + + {dateLabel(client.updatedAt)} + {client.group && ( {t('pages.clients.group')} - {client.group} + + {client.group} + )} {client.comment && ( {t('pages.clients.comment')} - {client.comment} + + {client.comment} + )} @@ -404,7 +496,9 @@ export default function ClientInfoModal({ <> {t('subscription.title')} {subJsonLink && (
- JSON + + JSON +
-
@@ -468,7 +608,9 @@ export default function ClientInfoModal({ {subClashLink && (
- CLASH + + CLASH +
-
@@ -513,13 +677,22 @@ export default function ClientInfoModal({ const canQr = !isPostQuantumLink(link); return (
- {parts - ? - : LINK} - {rowTitle} + {parts ? ( + + ) : ( + LINK + )} + + {rowTitle} +
- , - , } + extra={ + + } /> ) : ( @@ -1035,46 +1237,90 @@ export default function ClientsPage() { - } /> + } + /> } + content={ + + } > - } /> + } + /> } + content={ + + } > - } /> + } + /> } + content={ + + } > - } /> + } + /> } + content={ + + } > - } /> + } + /> - } /> + } + /> @@ -1087,7 +1333,12 @@ export default function ClientsPage() { title={
{selectedRowKeys.length === 0 ? ( - ) : ( @@ -1104,102 +1355,103 @@ export default function ClientsPage() { trigger={['click']} placement="bottomRight" menu={{ - items: selectedRowKeys.length > 0 - ? [ - { - key: 'attach', - icon: , - label: t('pages.clients.attach'), - onClick: () => setBulkAttachOpen(true), - }, - { - key: 'detach', - icon: , - label: t('pages.clients.detach'), - danger: true, - onClick: () => setBulkDetachOpen(true), - }, - { - key: 'addToGroup', - icon: , - label: t('pages.clients.addToGroup'), - onClick: () => setBulkGroupOpen(true), - }, - { - key: 'ungroup', - icon: , - label: t('pages.clients.ungroup'), - danger: true, - onClick: onBulkUngroup, - }, - { type: 'divider' as const }, - { - key: 'enable', - icon: , - label: t('pages.clients.enable'), - onClick: () => onBulkSetEnable(true), - }, - { - key: 'disable', - icon: , - label: t('pages.clients.disable'), - danger: true, - onClick: () => onBulkSetEnable(false), - }, - { - key: 'adjust', - icon: , - label: t('pages.clients.adjust'), - onClick: () => setBulkAdjustOpen(true), - }, - { - key: 'subLinks', - icon: , - label: t('pages.clients.subLinks'), - onClick: () => setSubLinksOpen(true), - }, - ] - : [ - { - key: 'bulk', - icon: , - label: t('pages.clients.bulk'), - onClick: () => setBulkAddOpen(true), - }, - { - key: 'export', - icon: , - label: t('pages.clients.exportClients'), - onClick: onExportClients, - }, - { - key: 'import', - icon: , - label: t('pages.clients.importClients'), - onClick: onImportClients, - }, - { - key: 'resetAll', - icon: , - label: t('pages.clients.resetAllTraffics'), - onClick: onResetAllTraffics, - }, - { type: 'divider' as const }, - { - key: 'delDepleted', - icon: , - label: t('pages.clients.delDepleted'), - danger: true, - onClick: onDelDepleted, - }, - { - key: 'delOrphans', - icon: , - label: t('pages.clients.delOrphans'), - danger: true, - onClick: onDeleteOrphans, - }, - ], + items: + selectedRowKeys.length > 0 + ? [ + { + key: 'attach', + icon: , + label: t('pages.clients.attach'), + onClick: () => setBulkAttachOpen(true), + }, + { + key: 'detach', + icon: , + label: t('pages.clients.detach'), + danger: true, + onClick: () => setBulkDetachOpen(true), + }, + { + key: 'addToGroup', + icon: , + label: t('pages.clients.addToGroup'), + onClick: () => setBulkGroupOpen(true), + }, + { + key: 'ungroup', + icon: , + label: t('pages.clients.ungroup'), + danger: true, + onClick: onBulkUngroup, + }, + { type: 'divider' as const }, + { + key: 'enable', + icon: , + label: t('pages.clients.enable'), + onClick: () => onBulkSetEnable(true), + }, + { + key: 'disable', + icon: , + label: t('pages.clients.disable'), + danger: true, + onClick: () => onBulkSetEnable(false), + }, + { + key: 'adjust', + icon: , + label: t('pages.clients.adjust'), + onClick: () => setBulkAdjustOpen(true), + }, + { + key: 'subLinks', + icon: , + label: t('pages.clients.subLinks'), + onClick: () => setSubLinksOpen(true), + }, + ] + : [ + { + key: 'bulk', + icon: , + label: t('pages.clients.bulk'), + onClick: () => setBulkAddOpen(true), + }, + { + key: 'export', + icon: , + label: t('pages.clients.exportClients'), + onClick: onExportClients, + }, + { + key: 'import', + icon: , + label: t('pages.clients.importClients'), + onClick: onImportClients, + }, + { + key: 'resetAll', + icon: , + label: t('pages.clients.resetAllTraffics'), + onClick: onResetAllTraffics, + }, + { type: 'divider' as const }, + { + key: 'delDepleted', + icon: , + label: t('pages.clients.delDepleted'), + danger: true, + onClick: onDelDepleted, + }, + { + key: 'delOrphans', + icon: , + label: t('pages.clients.delOrphans'), + danger: true, + onClick: onDeleteOrphans, + }, + ], }} >
@@ -1405,18 +1701,31 @@ export default function ClientsPage() { {filteredClients.map((row) => { const bucket = clientBucket(row); return ( -
+
toggleSelect(row.email, e.target.checked)} /> - {row.enable && bucket !== 'depleted' && isOnline(row.email) - ? - : } + {row.enable && bucket !== 'depleted' && isOnline(row.email) ? ( + + ) : ( + + )} {row.email} - {bucket === 'depleted' && {t('depleted')}} - {bucket === 'expiring' && {t('depletingSoon')}} + {bucket === 'depleted' && ( + + {t('depleted')} + + )} + {bucket === 'expiring' && ( + + {t('depletingSoon')} + + )}
{t('pages.clients.qrCode')}, + label: ( + <> + {t('pages.clients.qrCode')} + + ), onClick: () => onShowQr(row.email), }, { key: 'reset', - label: <> {t('pages.inbounds.resetTraffic')}, + label: ( + <> + {' '} + {t('pages.inbounds.resetTraffic')} + + ), onClick: () => onResetTraffic(row.email), }, { key: 'edit', - label: <> {t('edit')}, + label: ( + <> + {t('edit')} + + ), onClick: () => onEdit(row.email), }, { key: 'delete', danger: true, - label: <> {t('delete')}, + label: ( + <> + {t('delete')} + + ), onClick: () => onDelete(row.email), }, ], }} > -
@@ -1655,11 +1987,17 @@ export default function ClientsPage() { function bucketChipLabel(b: string, t: (k: string) => string): string { switch (b) { - case 'active': return t('subscription.active'); - case 'expiring': return t('depletingSoon'); - case 'depleted': return t('depleted'); - case 'deactive': return t('disabled'); - case 'online': return t('online'); - default: return b; + case 'active': + return t('subscription.active'); + case 'expiring': + return t('depletingSoon'); + case 'depleted': + return t('depleted'); + case 'deactive': + return t('disabled'); + case 'online': + return t('online'); + default: + return b; } } diff --git a/frontend/src/pages/clients/FilterDrawer.tsx b/frontend/src/pages/clients/FilterDrawer.tsx index 96e54f66e..3b0050016 100644 --- a/frontend/src/pages/clients/FilterDrawer.tsx +++ b/frontend/src/pages/clients/FilterDrawer.tsx @@ -52,10 +52,11 @@ export default function FilterDrawer({ } const inboundOptions = useMemo( - () => inbounds.map((ib) => ({ - value: ib.id, - label: formatInboundLabel(ib.tag, ib.remark), - })), + () => + inbounds.map((ib) => ({ + value: ib.id, + label: formatInboundLabel(ib.tag, ib.remark), + })), [inbounds], ); @@ -64,10 +65,7 @@ export default function FilterDrawer({ [protocols], ); - const groupOptions = useMemo( - () => groups.map((g) => ({ value: g, label: g })), - [groups], - ); + const groupOptions = useMemo(() => groups.map((g) => ({ value: g, label: g })), [groups]); // 0 is the "local panel" sentinel (inbounds without a nodeId) — see // ClientFilters.nodeIds (#4997). @@ -104,10 +102,7 @@ export default function FilterDrawer({ >
{t('status')}}> - patch('buckets', v as string[])} - > + patch('buckets', v as string[])}> {BUCKET_KEYS.map((k) => ( @@ -260,11 +255,17 @@ export default function FilterDrawer({ function bucketLabel(key: string, t: (k: string) => string): string { switch (key) { - case 'active': return t('subscription.active'); - case 'expiring': return t('depletingSoon'); - case 'depleted': return t('depleted'); - case 'deactive': return t('disabled'); - case 'online': return t('online'); - default: return key; + case 'active': + return t('subscription.active'); + case 'expiring': + return t('depletingSoon'); + case 'depleted': + return t('depleted'); + case 'deactive': + return t('disabled'); + case 'online': + return t('online'); + default: + return key; } } diff --git a/frontend/src/pages/clients/RowCells.tsx b/frontend/src/pages/clients/RowCells.tsx index 109cdc8d7..0bf202270 100644 --- a/frontend/src/pages/clients/RowCells.tsx +++ b/frontend/src/pages/clients/RowCells.tsx @@ -130,7 +130,9 @@ export const ClientInboundChips = memo(function ClientInboundChips({ const proto = (inboundsById[id]?.protocol || '').toLowerCase(); return ( - {label(id)} + + {label(id)} + ); }; @@ -146,7 +148,9 @@ export const ClientInboundChips = memo(function ClientInboundChips({ placement="bottomRight" content={
{overflow.map(chip)}
} > - +{overflow.length} + + +{overflow.length} + )} diff --git a/frontend/src/pages/clients/SubLinksModal.tsx b/frontend/src/pages/clients/SubLinksModal.tsx index 2aa93ed1b..012b42a9a 100644 --- a/frontend/src/pages/clients/SubLinksModal.tsx +++ b/frontend/src/pages/clients/SubLinksModal.tsx @@ -61,7 +61,12 @@ export default function SubLinksModal({ }, [emails, clients, enabled, jsonEnabled, subSettings]); const allText = useMemo( - () => rows.map((r) => (jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`)).join('\n'), + () => + rows + .map((r) => + jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`, + ) + .join('\n'), [rows, jsonEnabled], ); @@ -102,7 +107,9 @@ export default function SubLinksModal({ ellipsis: true, render: (link: string) => ( - {link} + + {link} + ), }, @@ -111,7 +118,13 @@ export default function SubLinksModal({ key: 'actions', width: 64, render: (_v, row) => ( - diff --git a/frontend/src/pages/clients/wireguardConfig.ts b/frontend/src/pages/clients/wireguardConfig.ts index 27196a0a4..705813269 100644 --- a/frontend/src/pages/clients/wireguardConfig.ts +++ b/frontend/src/pages/clients/wireguardConfig.ts @@ -4,7 +4,13 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients'; export function isWireguardClient(client: ClientRecord | null | undefined): boolean { if (!client) return false; - return !!(client.privateKey || client.publicKey || client.allowedIPs || client.preSharedKey || client.keepAlive); + return !!( + client.privateKey || + client.publicKey || + client.allowedIPs || + client.preSharedKey || + client.keepAlive + ); } export function findWireguardInbound( @@ -22,7 +28,11 @@ export function buildWireguardClientConfig( host = window.location.hostname, publicHost = '', ): string { - const endpointHost = resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost)); + const endpointHost = resolveShareHost( + inbound ?? {}, + inbound?.nodeAddress ?? '', + preferPublicHost(host, publicHost), + ); const address = client.allowedIPs || '10.0.0.2/32'; const endpoint = `${endpointHost}:${inbound?.port || ''}`; const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : ''; @@ -39,6 +49,7 @@ export function buildWireguardClientConfig( lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`); if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`); lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`); - if (client.keepAlive && client.keepAlive > 0) lines.push(`PersistentKeepalive = ${client.keepAlive}`); + if (client.keepAlive && client.keepAlive > 0) + lines.push(`PersistentKeepalive = ${client.keepAlive}`); return lines.join('\n'); } diff --git a/frontend/src/pages/groups/GroupAddClientsModal.tsx b/frontend/src/pages/groups/GroupAddClientsModal.tsx index fbc8056c6..5021c96c3 100644 --- a/frontend/src/pages/groups/GroupAddClientsModal.tsx +++ b/frontend/src/pages/groups/GroupAddClientsModal.tsx @@ -74,7 +74,11 @@ export default function GroupAddClientsModal({ width: 140, ellipsis: true, render: (g: string) => - g ? {g} : , + g ? ( + {g} + ) : ( + + ), }, { title: t('enable'), diff --git a/frontend/src/pages/groups/GroupsPage.tsx b/frontend/src/pages/groups/GroupsPage.tsx index 7631b34d9..040985dc0 100644 --- a/frontend/src/pages/groups/GroupsPage.tsx +++ b/frontend/src/pages/groups/GroupsPage.tsx @@ -57,7 +57,10 @@ import { } from '@/schemas/client'; import { parseMsg } from '@/utils/zodValidate'; -const ClientRecordListSchema = z.array(ClientRecordSchema).nullable().transform((v) => v ?? []); +const ClientRecordListSchema = z + .array(ClientRecordSchema) + .nullable() + .transform((v) => v ?? []); const SubLinksModal = lazy(() => import('../clients/SubLinksModal')); const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal')); @@ -90,10 +93,14 @@ export default function GroupsPage() { const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); const [messageApi, messageContextHolder] = message.useMessage(); - useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); + useEffect(() => { + setMessageInstance(messageApi); + }, [messageApi]); const queryClient = useQueryClient(); - const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false }); + const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ + list: false, + }); const groupsQuery = useQuery({ queryKey: keys.clients.groups(), @@ -111,25 +118,33 @@ export default function GroupsPage() { const createMut = useMutation({ mutationFn: (body: { name: string }) => HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS), - onSuccess: (msg) => { if (msg?.success) invalidate(); }, + onSuccess: (msg) => { + if (msg?.success) invalidate(); + }, }); const renameMut = useMutation({ mutationFn: (body: { oldName: string; newName: string }) => HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS), - onSuccess: (msg) => { if (msg?.success) invalidate(); }, + onSuccess: (msg) => { + if (msg?.success) invalidate(); + }, }); const deleteMut = useMutation({ mutationFn: (body: { name: string }) => HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS), - onSuccess: (msg) => { if (msg?.success) invalidate(); }, + onSuccess: (msg) => { + if (msg?.success) invalidate(); + }, }); const groupResetMut = useMutation({ mutationFn: (body: { name: string }) => HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS), - onSuccess: (msg) => { if (msg?.success) invalidate(); }, + onSuccess: (msg) => { + if (msg?.success) invalidate(); + }, }); const [createOpen, setCreateOpen] = useState(false); @@ -168,14 +183,8 @@ export default function GroupsPage() { () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0), [groups], ); - const totalUpload = useMemo( - () => groups.reduce((acc, g) => acc + (g.up || 0), 0), - [groups], - ); - const totalDownload = useMemo( - () => groups.reduce((acc, g) => acc + (g.down || 0), 0), - [groups], - ); + const totalUpload = useMemo(() => groups.reduce((acc, g) => acc + (g.up || 0), 0), [groups]); + const totalDownload = useMemo(() => groups.reduce((acc, g) => acc + (g.down || 0), 0), [groups]); function openCreate() { setCreateName(''); @@ -209,7 +218,11 @@ export default function GroupsPage() { setRenameOpen(false); return; } - if (groups.some((g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name)) { + if ( + groups.some( + (g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name, + ) + ) { messageApi.error(t('pages.groups.renameCollision', { name: next })); return; } @@ -305,9 +318,11 @@ export default function GroupsPage() { messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok })); } else { const firstError = skipped[0]?.reason ?? msg?.msg ?? ''; - messageApi.warning(firstError - ? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}` - : t('pages.groups.deleteClientsMixed', { ok, failed })); + messageApi.warning( + firstError + ? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}` + : t('pages.groups.deleteClientsMixed', { ok, failed }), + ); } } }, @@ -404,10 +419,23 @@ export default function GroupsPage() { render: (_v, row) => ( - } + extra={ + + } /> ) : ( @@ -522,7 +558,12 @@ export default function GroupsPage() { hoverable title={
-
diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index 2573b9df7..d215ea98c 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -66,7 +66,8 @@ function defaultsFor(host: HostRecord | null): FormShape { sockoptParams: asString(host?.sockoptParams), finalMask: host?.finalMask ?? '', vlessRoute: host?.vlessRoute ?? '', - excludeFromSubTypes: (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [], + excludeFromSubTypes: + (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [], nodeGuids: host?.nodeGuids ?? [], mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'], mihomoX25519: host?.mihomoX25519 ?? false, @@ -74,7 +75,15 @@ function defaultsFor(host: HostRecord | null): FormShape { }; } -export default function HostFormModal({ open, mode, host, inboundOptions, existingHosts, save, onOpenChange }: HostFormModalProps) { +export default function HostFormModal({ + open, + mode, + host, + inboundOptions, + existingHosts, + save, + onOpenChange, +}: HostFormModalProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); const methods = useForm({ defaultValues: defaultsFor(host) }); @@ -95,22 +104,30 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi const { nodes } = useNodesQuery(); const inboundSelectOptions = useMemo( - () => inboundOptions.map((ib) => ({ - value: ib.id, - label: ib.remark || ib.tag || `#${ib.id}`, - })), + () => + inboundOptions.map((ib) => ({ + value: ib.id, + label: ib.remark || ib.tag || `#${ib.id}`, + })), [inboundOptions], ); const nodeSelectOptions = useMemo( - () => nodes - .filter((n) => n.guid) - .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })), + () => + nodes + .filter((n) => n.guid) + .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })), [nodes], ); - const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []); - const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []); + const alpnOptions = useMemo( + () => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), + [], + ); + const fpOptions = useMemo( + () => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), + [], + ); const hostOptions = useMemo(() => { const addresses = new Set(); @@ -139,7 +156,9 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi try { const res = await save(payload); if (res?.success) { - messageApi.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update')); + messageApi.success( + t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'), + ); onOpenChange(false); } else if (res?.msg) { messageApi.error(res.msg); @@ -181,13 +200,26 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi label: catTabLabel(, t('pages.hosts.sections.basic'), isMobile), children: ( <> - + - + - + - + - + + + ({ value: v, label: v }))} + options={['same', 'tls', 'none', 'reality'].map((v) => ({ + value: v, + label: v, + }))} /> {showTls && ( @@ -234,10 +299,18 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi - + - + @@ -253,13 +326,25 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi - + - + @@ -270,7 +355,11 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi { key: 'advanced', forceRender: true, - label: catTabLabel(, t('pages.hosts.sections.advanced'), isMobile), + label: catTabLabel( + , + t('pages.hosts.sections.advanced'), + isMobile, + ), children: ( , t('pages.hosts.sections.general'), isMobile), + label: catTabLabel( + , + t('pages.hosts.sections.general'), + isMobile, + ), children: ( <> @@ -288,14 +381,24 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi - + - + ({ value: v, label: v }))} + options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map( + (v) => ({ value: v, label: v }), + )} /> - + - + diff --git a/frontend/src/pages/hosts/HostList.tsx b/frontend/src/pages/hosts/HostList.tsx index 88d2524e9..260b6064f 100644 --- a/frontend/src/pages/hosts/HostList.tsx +++ b/frontend/src/pages/hosts/HostList.tsx @@ -56,8 +56,19 @@ export function sortHosts(hosts: HostRecord[]): HostRecord[] { export default function HostList(props: HostListProps) { const { t } = useTranslation(); const { - hosts, inboundOptions, loading, isMobile, selectedGroupIds, onSelectionChange, - onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete, + hosts, + inboundOptions, + loading, + isMobile, + selectedGroupIds, + onSelectionChange, + onAdd, + onEdit, + onDelete, + onToggleEnable, + onMove, + onBulkEnable, + onBulkDelete, } = props; const inboundsMap = useMemo(() => { @@ -78,16 +89,43 @@ export default function HostList(props: HostListProps) { return ( - - + )}
diff --git a/frontend/src/pages/hosts/HostsPage.tsx b/frontend/src/pages/hosts/HostsPage.tsx index 048b1065c..c873d1989 100644 --- a/frontend/src/pages/hosts/HostsPage.tsx +++ b/frontend/src/pages/hosts/HostsPage.tsx @@ -1,6 +1,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd'; +import { + Button, + Card, + Col, + ConfigProvider, + Layout, + Modal, + Result, + Row, + Spin, + Statistic, + message, +} from 'antd'; import { CheckCircleOutlined, GlobalOutlined, StopOutlined } from '@ant-design/icons'; import { useTheme } from '@/hooks/useTheme'; @@ -20,10 +32,13 @@ export default function HostsPage() { const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); const [messageApi, messageContextHolder] = message.useMessage(); - useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); + useEffect(() => { + setMessageInstance(messageApi); + }, [messageApi]); const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery(); - const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations(); + const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = + useHostMutations(); const { data: inboundOptions = [] } = useInboundOptions(); const [formOpen, setFormOpen] = useState(false); @@ -43,45 +58,60 @@ export default function HostsPage() { setFormOpen(true); }, []); - const onSave = useCallback(async (payload: BulkAddHostValues) => { - if (formMode === 'edit' && formHost?.groupId) { - return update(formHost.groupId, payload); - } - return bulkCreate(payload); - }, [formMode, formHost, update, bulkCreate]); + const onSave = useCallback( + async (payload: BulkAddHostValues) => { + if (formMode === 'edit' && formHost?.groupId) { + return update(formHost.groupId, payload); + } + return bulkCreate(payload); + }, + [formMode, formHost, update, bulkCreate], + ); - const onDelete = useCallback((host: HostRecord) => { - modal.confirm({ - title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }), - okText: t('delete'), - okType: 'danger', - cancelText: t('cancel'), - onOk: async () => { - const msg = await remove(host.groupId); - if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete')); - }, - }); - }, [modal, t, remove, messageApi]); + const onDelete = useCallback( + (host: HostRecord) => { + modal.confirm({ + title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }), + okText: t('delete'), + okType: 'danger', + cancelText: t('cancel'), + onOk: async () => { + const msg = await remove(host.groupId); + if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete')); + }, + }); + }, + [modal, t, remove, messageApi], + ); - const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => { - await setEnable(host.groupId, next); - }, [setEnable]); + const onToggleEnable = useCallback( + async (host: HostRecord, next: boolean) => { + await setEnable(host.groupId, next); + }, + [setEnable], + ); - const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => { - const sorted = sortHosts(hosts); - const idx = sorted.findIndex((h) => h.groupId === host.groupId); - const swapWith = dir === 'up' ? idx - 1 : idx + 1; - if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return; - const groupIds = sorted.map((h) => h.groupId); - [groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]]; - await reorder(groupIds); - }, [hosts, reorder]); + const onMove = useCallback( + async (host: HostRecord, dir: 'up' | 'down') => { + const sorted = sortHosts(hosts); + const idx = sorted.findIndex((h) => h.groupId === host.groupId); + const swapWith = dir === 'up' ? idx - 1 : idx + 1; + if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return; + const groupIds = sorted.map((h) => h.groupId); + [groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]]; + await reorder(groupIds); + }, + [hosts, reorder], + ); - const onBulkEnable = useCallback(async (enable: boolean) => { - if (selectedGroupIds.length === 0) return; - const msg = await bulkSetEnable(selectedGroupIds, enable); - if (msg?.success) setSelectedGroupIds([]); - }, [selectedGroupIds, bulkSetEnable]); + const onBulkEnable = useCallback( + async (enable: boolean) => { + if (selectedGroupIds.length === 0) return; + const msg = await bulkSetEnable(selectedGroupIds, enable); + if (msg?.success) setSelectedGroupIds([]); + }, + [selectedGroupIds, bulkSetEnable], + ); const onBulkDelete = useCallback(() => { if (selectedGroupIds.length === 0) return; @@ -129,7 +159,11 @@ export default function HostsPage() { status="error" title={t('somethingWentWrong')} subTitle={fetchError} - extra={} + extra={ + + } /> ) : ( @@ -147,14 +181,18 @@ export default function HostsPage() { } + prefix={ + + } /> } + prefix={ + + } /> diff --git a/frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx b/frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx index 5c47ecb70..89a7e94ab 100644 --- a/frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx +++ b/frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx @@ -25,7 +25,13 @@ function parseFinalMask(raw: string): FinalMaskStreamSettings { return { tcp: [], udp: [] }; } -export default function HostFinalMaskForm({ value = '', onChange }: { value?: string; onChange?: (next: string) => void }) { +export default function HostFinalMaskForm({ + value = '', + onChange, +}: { + value?: string; + onChange?: (next: string) => void; +}) { const [form] = Form.useForm(); const [initial] = useState(() => parseFinalMask(value)); const onChangeRef = useRef(onChange); diff --git a/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx b/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx index 809e5b3f6..aff829620 100644 --- a/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx +++ b/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx @@ -9,16 +9,29 @@ import { serializeOverride } from './helpers'; * the sub-JSON editor; the host stores '' (= inherit the inbound/global mux) * when the toggle is off, an explicit mux object when on. */ -const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' }; +const DEFAULT_MUX = { + enabled: false, + concurrency: 8, + xudpConcurrency: 16, + xudpProxyUDP443: 'reject', +}; -export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) { +export default function HostMuxForm({ + value, + onChange, +}: { + value?: string; + onChange?: (next: string) => void; +}) { return ( ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')} + serialize={(mux) => + (mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '' + } /* protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate; a host's mux override is protocol-agnostic and should always be editable. */ render={() => } diff --git a/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx b/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx index 0b03d3c69..12f45ea5f 100644 --- a/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx +++ b/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx @@ -25,7 +25,13 @@ function serializeClientSockopt(sockopt: unknown): string { return serializeOverride(copy); } -export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) { +export default function HostSockoptForm({ + value, + onChange, +}: { + value?: string; + onChange?: (next: string) => void; +}) { /* * Populate the dialerProxy dropdown with the panel's outbound tags (a host can * chain through one of the subscription's outbounds by tag). dialerProxy chains diff --git a/frontend/src/pages/inbounds/CloneInboundModal.tsx b/frontend/src/pages/inbounds/CloneInboundModal.tsx index e83aa96ed..f03136a36 100644 --- a/frontend/src/pages/inbounds/CloneInboundModal.tsx +++ b/frontend/src/pages/inbounds/CloneInboundModal.tsx @@ -34,21 +34,29 @@ export default function CloneInboundModal({ const [targets, setTargets] = useState([LOCAL_PANEL]); const [submitting, setSubmitting] = useState(false); - const targetOptions = useMemo(() => [ - { value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false }, - ...(nodes || []).filter((n) => n.enable).map((n) => ({ - value: n.id, - // Only online nodes are deployable targets: nodes report `unknown` - // until their first heartbeat, and the backend refuses any status - // other than online. - label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`, - disabled: n.status !== 'online', - })), - ], [nodes, t]); + const targetOptions = useMemo( + () => [ + { value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false }, + ...(nodes || []) + .filter((n) => n.enable) + .map((n) => ({ + value: n.id, + // Only online nodes are deployable targets: nodes report `unknown` + // until their first heartbeat, and the backend refuses any status + // other than online. + label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`, + disabled: n.status !== 'online', + })), + ], + [nodes, t], + ); // "Select all" must not pick targets the user can't pick manually — // offline nodes are disabled options in the dropdown. - const selectableOptions = useMemo(() => targetOptions.filter((o) => !o.disabled), [targetOptions]); + const selectableOptions = useMemo( + () => targetOptions.filter((o) => !o.disabled), + [targetOptions], + ); // Reset the selection when the dialog OPENS: pre-select the source // inbound's own node when it is a selectable target, otherwise the local @@ -75,17 +83,23 @@ export default function CloneInboundModal({ for (const target of targets) { const msg = await HttpUtil.post( '/panel/api/inbounds/add', - buildClonePayload(dbInbound, pickClonePort(portsInUse.get(target)), target === LOCAL_PANEL ? null : target), + buildClonePayload( + dbInbound, + pickClonePort(portsInUse.get(target)), + target === LOCAL_PANEL ? null : target, + ), { silent: true }, ); - results.push({ ok: !!msg?.success, reason: msg?.success ? '' : (msg?.msg || '') }); + results.push({ ok: !!msg?.success, reason: msg?.success ? '' : msg?.msg || '' }); } const okCount = results.filter((r) => r.ok).length; const failed = results.length - okCount; if (failed === 0) { - messageApi.success(okCount === 1 - ? t('pages.inbounds.toasts.inboundCreateSuccess') - : t('pages.inbounds.toasts.clonedMany', { count: okCount })); + messageApi.success( + okCount === 1 + ? t('pages.inbounds.toasts.inboundCreateSuccess') + : t('pages.inbounds.toasts.clonedMany', { count: okCount }), + ); } else { const firstError = results.find((r) => !r.ok)?.reason ?? ''; const base = t('pages.inbounds.toasts.clonedMixed', { ok: okCount, failed }); @@ -114,11 +128,7 @@ export default function CloneInboundModal({ {t('pages.inbounds.cloneConfirmContent')} - + strategy !== 'node' || nodeShareOptionAvailable) - .map((strategy) => ({ - value: strategy, - label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`), - }))} + options={SHARE_ADDR_STRATEGIES.filter( + (strategy) => strategy !== 'node' || nodeShareOptionAvailable, + ).map((strategy) => ({ + value: strategy, + label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`), + }))} /> {shareAddrStrategy === 'custom' && ( isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'), @@ -627,7 +641,10 @@ export default function InboundFormModal({ @@ -636,7 +653,10 @@ export default function InboundFormModal({ @@ -716,7 +736,9 @@ export default function InboundFormModal({ const protocolTab = ( <> - {protocol === Protocols.WIREGUARD && } + {protocol === Protocols.WIREGUARD && ( + + )} {protocol === Protocols.TUN && } @@ -729,11 +751,22 @@ export default function InboundFormModal({ {protocol === Protocols.SHADOWSOCKS && } - {protocol === Protocols.VLESS && } + {protocol === Protocols.VLESS && ( + + )} {isFallbackHost && fallbacksCard} - {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) - && network === 'tcp' && !isFallbackHost && ( + {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) && + network === 'tcp' && + !isFallbackHost && ( { - const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings']; + const ALL = [ + 'tcpSettings', + 'kcpSettings', + 'wsSettings', + 'grpcSettings', + 'httpupgradeSettings', + 'xhttpSettings', + ]; const current = (getV('streamSettings') as Record) ?? {}; const cleaned: Record = { ...current, network: next }; for (const k of ALL) { @@ -773,7 +813,9 @@ export default function InboundFormModal({ } else { const fm = cleaned.finalmask as Record | undefined; if (fm && Array.isArray(fm.udp)) { - const udp = (fm.udp as unknown[]).filter((m) => (m as { type?: string })?.type !== 'mkcp-legacy'); + const udp = (fm.udp as unknown[]).filter( + (m) => (m as { type?: string })?.type !== 'mkcp-legacy', + ); cleaned.finalmask = { ...fm, udp }; } } @@ -914,10 +956,11 @@ export default function InboundFormModal({ label: t('pages.inbounds.advanced.all'), children: ( <> -
- {t('pages.inbounds.advanced.allHelp')} -
- +
{t('pages.inbounds.advanced.allHelp')}
+ ), }, @@ -940,44 +983,48 @@ export default function InboundFormModal({ ), }, ...(streamEnabled - ? [{ - key: 'stream', - label: t('pages.inbounds.advanced.stream'), - children: ( - <> -
- {t('pages.inbounds.advanced.streamHelp')}{' '} - {'{ streamSettings: { ... } }'}. -
- - - ), - }] + ? [ + { + key: 'stream', + label: t('pages.inbounds.advanced.stream'), + children: ( + <> +
+ {t('pages.inbounds.advanced.streamHelp')}{' '} + {'{ streamSettings: { ... } }'}. +
+ + + ), + }, + ] : []), ...(sniffingSupported - ? [{ - key: 'sniffing', - label: t('pages.inbounds.advanced.sniffing'), - children: ( - <> -
- {t('pages.inbounds.advanced.sniffingHelp')}{' '} - {'{ sniffing: { ... } }'}. -
- - - ), - }] + ? [ + { + key: 'sniffing', + label: t('pages.inbounds.advanced.sniffing'), + children: ( + <> +
+ {t('pages.inbounds.advanced.sniffingHelp')}{' '} + {'{ sniffing: { ... } }'}. +
+ + + ), + }, + ] : []), ]} /> @@ -1010,33 +1057,75 @@ export default function InboundFormModal({ wrapperCol={{ sm: { span: 14 } }} labelWrap > - + diff --git a/frontend/src/pages/inbounds/form/SniffingTab.tsx b/frontend/src/pages/inbounds/form/SniffingTab.tsx index 9bc502590..168855823 100644 --- a/frontend/src/pages/inbounds/form/SniffingTab.tsx +++ b/frontend/src/pages/inbounds/form/SniffingTab.tsx @@ -11,11 +11,7 @@ export default function SniffingTab() { control={control} name="sniffing" render={({ field }) => ( - + )} /> ); diff --git a/frontend/src/pages/inbounds/form/advanced-editors.tsx b/frontend/src/pages/inbounds/form/advanced-editors.tsx index b19a9b691..7c6866f08 100644 --- a/frontend/src/pages/inbounds/form/advanced-editors.tsx +++ b/frontend/src/pages/inbounds/form/advanced-editors.tsx @@ -67,9 +67,10 @@ export function AdvancedSliceEditor({ setText(next); try { const parsed = JSON.parse(next); - const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record)[wrapKey] ?? {} - : parsed; + const toWrite = + wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? ((parsed as Record)[wrapKey] ?? {}) + : parsed; setValue(path, toWrite); lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2); } catch { @@ -146,7 +147,17 @@ export function AdvancedAllEditor({ setText(formStr); lastEmitRef.current = formStr; /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]); + }, [ + wListen, + wPort, + wProtocol, + wTag, + wSettings, + wSniffing, + wStream, + streamEnabled, + sniffingEnabled, + ]); return ( diff --git a/frontend/src/pages/inbounds/form/protocols/hysteria.tsx b/frontend/src/pages/inbounds/form/protocols/hysteria.tsx index 343a084fb..68f3b7dc9 100644 --- a/frontend/src/pages/inbounds/form/protocols/hysteria.tsx +++ b/frontend/src/pages/inbounds/form/protocols/hysteria.tsx @@ -13,9 +13,10 @@ export default function HysteriaFields() { const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as | { type?: string } | undefined; - const masqType = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade.type' }) as - | string - | undefined; + const masqType = useWatch({ + control, + name: 'streamSettings.hysteriaSettings.masquerade.type', + }) as string | undefined; return ( <> {masq && ( <> - + )} {masqType === 'file' && ( - + )} @@ -103,16 +100,10 @@ export default function HysteriaFields() { > - + - + diff --git a/frontend/src/pages/inbounds/form/protocols/mixed.tsx b/frontend/src/pages/inbounds/form/protocols/mixed.tsx index 80fce4651..f6dbfcddd 100644 --- a/frontend/src/pages/inbounds/form/protocols/mixed.tsx +++ b/frontend/src/pages/inbounds/form/protocols/mixed.tsx @@ -17,11 +17,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) { ]} /> - + {mixedUdpOn && ( diff --git a/frontend/src/pages/inbounds/form/protocols/mtproto.tsx b/frontend/src/pages/inbounds/form/protocols/mtproto.tsx index b61bb27f2..47046c3b7 100644 --- a/frontend/src/pages/inbounds/form/protocols/mtproto.tsx +++ b/frontend/src/pages/inbounds/form/protocols/mtproto.tsx @@ -8,7 +8,9 @@ import { useOutboundTags } from '@/api/queries/useOutboundTags'; export default function MtprotoFields() { const { t } = useTranslation(); const { control } = useFormContext(); - const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as boolean | undefined; + const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as + | boolean + | undefined; const { data: outboundTags } = useOutboundTags(); return ( <> @@ -26,7 +28,10 @@ export default function MtprotoFields() { > - + - + - ({ value: m, label: m }))} /> {isSSWith2022 && ( @@ -57,11 +55,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro ]} /> - + diff --git a/frontend/src/pages/inbounds/form/protocols/tunnel.tsx b/frontend/src/pages/inbounds/form/protocols/tunnel.tsx index ea44922aa..ac965b3cd 100644 --- a/frontend/src/pages/inbounds/form/protocols/tunnel.tsx +++ b/frontend/src/pages/inbounds/form/protocols/tunnel.tsx @@ -8,13 +8,19 @@ export default function TunnelFields() { const { t } = useTranslation(); return ( <> - + - + (
-
{target}
+
+ {target} +
{row.ip ?
{row.ip}
: null}
diff --git a/frontend/src/pages/inbounds/form/security/reality.tsx b/frontend/src/pages/inbounds/form/security/reality.tsx index f57c7ff81..575d71a19 100644 --- a/frontend/src/pages/inbounds/form/security/reality.tsx +++ b/frontend/src/pages/inbounds/form/security/reality.tsx @@ -1,7 +1,19 @@ import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; +import { + Alert, + Button, + Collapse, + Descriptions, + Divider, + Form, + Input, + InputNumber, + Select, + Space, + Switch, +} from 'antd'; import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; @@ -52,9 +64,10 @@ export default function RealityForm({ * worth reading, so subject/issuer stay visible and only the verdict is added. */ const certSummary = (r: RealityScanResult) => { - const who = r.certSubject && r.certIssuer - ? `${r.certSubject} (${r.certIssuer})` - : r.certSubject || r.certIssuer; + const who = + r.certSubject && r.certIssuer + ? `${r.certSubject} (${r.certIssuer})` + : r.certSubject || r.certIssuer; if (!who) return '—'; return r.certValid ? who : `${who} — ${t('pages.inbounds.form.scanCertInvalid')}`; }; @@ -73,16 +86,17 @@ export default function RealityForm({ >
- + - ({ value: fp, label: fp }))} /> - - + {t('pages.inbounds.form.getNewSeed')} - + void; } -function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFiles }: CertRowProps) { +function CertRow({ + index, + total, + saving, + onRemove, + setCertFromPanel, + clearCertFiles, +}: CertRowProps) { const { t } = useTranslation(); const { control } = useFormContext(); - const useFile = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.useFile` }); - const usage = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.usage` }); + const useFile = useWatch({ + control, + name: `streamSettings.tlsSettings.certificates.${index}.useFile`, + }); + const usage = useWatch({ + control, + name: `streamSettings.tlsSettings.certificates.${index}.usage`, + }); return (
- - {t('pages.inbounds.certificatePath')} - - - {t('pages.inbounds.certificateContent')} - + {t('pages.inbounds.certificatePath')} + {t('pages.inbounds.certificateContent')} {total > 1 && ( @@ -83,11 +97,7 @@ function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFi - @@ -342,7 +357,10 @@ export default function TlsForm({ - + {t('pages.inbounds.form.getNewEchCert')} - + - + - + - + - + - + applyRealClientIpPreset(v as RealClientIpPreset)} options={[ { value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') }, - { value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') }, + { + value: 'cloudflare', + label: t('pages.inbounds.form.realClientIpPresetCloudflare'), + }, { value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') }, ]} /> @@ -124,7 +129,10 @@ export default function SockoptForm({ title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')} /> )} - + - + Promise export default function XhttpForm() { const { t } = useTranslation(); const { control, getValues, setValue } = useFormContext(); - const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as string | undefined; - const xhttpObfsMode = !!useWatch({ control, name: 'streamSettings.xhttpSettings.xPaddingObfsMode' }); - const xhttpSessionIDPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDPlacement' }) as string | undefined; - const xhttpSessionIDTable = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDTable' }); - const xhttpSeqPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.seqPlacement' }) as string | undefined; - const xhttpUplinkPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.uplinkDataPlacement' }) as string | undefined; + const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as + | string + | undefined; + const xhttpObfsMode = !!useWatch({ + control, + name: 'streamSettings.xhttpSettings.xPaddingObfsMode', + }); + const xhttpSessionIDPlacement = useWatch({ + control, + name: 'streamSettings.xhttpSettings.sessionIDPlacement', + }) as string | undefined; + const xhttpSessionIDTable = useWatch({ + control, + name: 'streamSettings.xhttpSettings.sessionIDTable', + }); + const xhttpSeqPlacement = useWatch({ + control, + name: 'streamSettings.xhttpSettings.seqPlacement', + }) as string | undefined; + const xhttpUplinkPlacement = useWatch({ + control, + name: 'streamSettings.xhttpSettings.uplinkDataPlacement', + }) as string | undefined; const enableXmux = !!useWatch({ control, name: 'streamSettings.xhttpSettings.enableXmux' }); function onXmuxToggle(checked: boolean) { @@ -60,7 +77,10 @@ export default function XhttpForm() { - + updateSetting({ smtpHost: e.target.value })} /> - - - } description={t('pages.settings.smtpPortDesc')}> - updateSetting({ smtpPort: v }))} /> - - - - updateSetting({ smtpUsername: e.target.value })} /> - - - - updateSetting({ smtpPassword: v })} - onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} /> - - - - updateSetting({ smtpFrom: e.target.value })} /> - - - - updateSetting({ smtpFromName: e.target.value })} /> - - - - updateSetting({ smtpTo: e.target.value })} /> - - - - updateSetting({ smtpHost: e.target.value })} + /> + + + } + description={t('pages.settings.smtpPortDesc')} + > + updateSetting({ smtpPort: v }))} + /> + + + + updateSetting({ smtpUsername: e.target.value })} + /> + + + + updateSetting({ smtpPassword: v })} + onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} + /> + + + + updateSetting({ smtpFrom: e.target.value })} + /> + + + + updateSetting({ smtpFromName: e.target.value })} + /> + + + + updateSetting({ smtpTo: e.target.value })} + /> + + + + updateSetting({ webListen: e.target.value })} /> - - - - updateSetting({ webDomain: e.target.value })} /> - - - } description={t('pages.settings.panelPortDesc')}> - updateSetting({ webPort: v }))} /> - - - - updateSetting({ webBasePath: sanitizePath(e.target.value) })} /> - - - } description={t('pages.settings.sessionMaxAgeDesc')}> - updateSetting({ sessionMaxAge: v }))} /> - - - - updateSetting({ trustedProxyCIDRs: e.target.value })} - /> - - - - updateSetting({ ipLimitAllowlist: e.target.value })} - /> - - - - - - - ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.notifications'), isMobile), - children: ( - <> - } description={t('pages.settings.expireTimeDiffDesc')}> - updateSetting({ expireDiff: v }))} /> - - } description={t('pages.settings.trafficDiffDesc')}> - updateSetting({ trafficDiff: v }))} /> - - - ), - }, - { - key: '3', - label: catTabLabel(, t('pages.settings.certs'), isMobile), - children: ( - <> - - updateSetting({ webCertFile: e.target.value })} /> - - - updateSetting({ webKeyFile: e.target.value })} /> - - - ), - }, - { - key: '4', - label: catTabLabel(, t('pages.settings.externalTraffic'), isMobile), - children: ( - <> - - updateSetting({ externalTrafficInformEnable: v })} /> - - - updateSetting({ externalTrafficInformURI: e.target.value })} - /> - - - ), - }, - { - key: '5', - label: catTabLabel(, t('pages.settings.dateAndTime'), isMobile), - children: ( - <> - - updateSetting({ timeLocation: e.target.value })} /> - - - updateSetting({ ldapHost: e.target.value })} /> - - }> - updateSetting({ ldapPort: v }))} /> - - - updateSetting({ ldapUseTLS: v })} /> - - - updateSetting({ ldapInsecureSkipVerify: v })} - /> - - - updateSetting({ ldapBindDN: e.target.value })} /> - - - updateSetting({ ldapPassword: v })} - onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })} - /> - - - updateSetting({ ldapBaseDN: e.target.value })} /> - - - updateSetting({ ldapUserFilter: e.target.value })} /> - - - updateSetting({ ldapUserAttr: e.target.value })} /> - - - updateSetting({ ldapVlessField: e.target.value })} /> - - - updateSetting({ ldapFlagField: e.target.value })} /> - - - updateSetting({ ldapTruthyValues: e.target.value })} /> - - - updateSetting({ ldapInvertFlag: v })} /> - - - updateSetting({ ldapSyncCron: e.target.value })} /> - - - <> - updateSetting({ webListen: e.target.value })} /> - {inboundOptions.length === 0 && ( -
{t('pages.settings.ldap.noInbounds')}
- )} - -
- - updateSetting({ ldapAutoCreate: v })} /> - - - updateSetting({ ldapAutoDelete: v })} /> - - }> - updateSetting({ ldapDefaultTotalGB: v }))} /> - - }> - updateSetting({ ldapDefaultExpiryDays: v }))} /> - - }> - updateSetting({ ldapDefaultLimitIP: v }))} /> - - - ), - }, - ]} /> + + + + updateSetting({ webDomain: e.target.value })} + /> + + + } + description={t('pages.settings.panelPortDesc')} + > + updateSetting({ webPort: v }))} + /> + + + + updateSetting({ webBasePath: sanitizePath(e.target.value) })} + /> + + + + } + description={t('pages.settings.sessionMaxAgeDesc')} + > + updateSetting({ sessionMaxAge: v }))} + /> + + + + updateSetting({ trustedProxyCIDRs: e.target.value })} + /> + + + + updateSetting({ ipLimitAllowlist: e.target.value })} + /> + + + + + + + ), + }, + { + key: '2', + label: catTabLabel(, t('pages.settings.notifications'), isMobile), + children: ( + <> + } + description={t('pages.settings.expireTimeDiffDesc')} + > + updateSetting({ expireDiff: v }))} + /> + + + } + description={t('pages.settings.trafficDiffDesc')} + > + updateSetting({ trafficDiff: v }))} + /> + + + ), + }, + { + key: '3', + label: catTabLabel(, t('pages.settings.certs'), isMobile), + children: ( + <> + + updateSetting({ webCertFile: e.target.value })} + /> + + + updateSetting({ webKeyFile: e.target.value })} + /> + + + ), + }, + { + key: '4', + label: catTabLabel(, t('pages.settings.externalTraffic'), isMobile), + children: ( + <> + + updateSetting({ externalTrafficInformEnable: v })} + /> + + + updateSetting({ externalTrafficInformURI: e.target.value })} + /> + + + ), + }, + { + key: '5', + label: catTabLabel(, t('pages.settings.dateAndTime'), isMobile), + children: ( + <> + + updateSetting({ timeLocation: e.target.value })} + /> + + + updateSetting({ ldapHost: e.target.value })} + /> + + } + > + updateSetting({ ldapPort: v }))} + /> + + + updateSetting({ ldapUseTLS: v })} + /> + + + updateSetting({ ldapInsecureSkipVerify: v })} + /> + + + updateSetting({ ldapBindDN: e.target.value })} + /> + + + updateSetting({ ldapPassword: v })} + onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })} + /> + + + updateSetting({ ldapBaseDN: e.target.value })} + /> + + + updateSetting({ ldapUserFilter: e.target.value })} + /> + + + updateSetting({ ldapUserAttr: e.target.value })} + /> + + + updateSetting({ ldapVlessField: e.target.value })} + /> + + + updateSetting({ ldapFlagField: e.target.value })} + /> + + + updateSetting({ ldapTruthyValues: e.target.value })} + /> + + + updateSetting({ ldapInvertFlag: v })} + /> + + + updateSetting({ ldapSyncCron: e.target.value })} + /> + + + <> + updateUserField('oldUsername', e.target.value)} /> + , t('pages.settings.security.admin'), isMobile), + children: ( + <> + + updateUserField('oldUsername', e.target.value)} + /> + + + updateUserField('oldPassword', e.target.value)} + /> + + + updateUserField('newUsername', e.target.value)} + /> + + + updateUserField('newPassword', e.target.value)} + /> + +
+ + + +
+ + ), + }, + { + key: '2', + label: catTabLabel( + , + t('pages.settings.security.twoFactor'), + isMobile, + ), + children: ( + + - - updateUserField('oldPassword', e.target.value)} /> - - - updateUserField('newUsername', e.target.value)} /> - - - updateUserField('newPassword', e.target.value)} /> - -
- - - -
- - ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.security.twoFactor'), isMobile), - children: ( - - - - ), - }, - { - key: '3', - label: catTabLabel(, t('pages.nodes.apiToken'), isMobile), - children: ( -
-
-

{t('pages.nodes.apiTokenHint')}

- -
- - {!apiTokens.length && !apiTokensLoading && ( - - )} - {apiTokens.map((row) => ( -
-
-
- {row.name} - {formatTokenDate(row.createdAt)} -
-
- toggleTokenEnabled(row)} /> - +
+ + {!apiTokens.length && !apiTokensLoading && ( + + )} + {apiTokens.map((row) => ( +
+
+
+ {row.name} + + {formatTokenDate(row.createdAt)} + +
+
+ toggleTokenEnabled(row)} + /> + +
-
- ))} - -
- ), - }, - ]} /> + ))} +
+
+ ), + }, + ]} + /> setCreateName(e.target.value)} onPressEnter={confirmCreateToken} /> @@ -366,12 +404,16 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }: cancelButtonProps={{ style: { display: 'none' } }} >

- {t('pages.settings.security.apiTokenCreatedNotice') - || 'Copy this token now. For security it is not stored in readable form and will not be shown again.'} + {t('pages.settings.security.apiTokenCreatedNotice') || + 'Copy this token now. For security it is not stored in readable form and will not be shown again.'}

{createdToken?.token} -
diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 909597cc2..8926cd7a6 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -35,7 +35,14 @@ interface ApiMsg { success?: boolean; } -const tabSlugs = ['general', 'security', 'telegram', 'email', 'subscription', 'subscription-formats']; +const tabSlugs = [ + 'general', + 'security', + 'telegram', + 'email', + 'subscription', + 'subscription-formats', +]; function isIp(h: string): boolean { if (typeof h !== 'string') return false; @@ -84,12 +91,10 @@ export default function SettingsPage() { const [entryIsIP, setEntryIsIP] = useState(false); useEffect(() => { - const host = window.location.hostname; setEntryHost(host); setEntryPort(window.location.port); setEntryIsIP(isIp(host)); - }, []); const [alertVisible, setAlertVisible] = useState(true); @@ -99,7 +104,7 @@ export default function SettingsPage() { function rebuildUrlAfterRestart(): string { const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting; - const newProtocol = (webCertFile || webKeyFile) ? 'https:' : 'http:'; + const newProtocol = webCertFile || webKeyFile ? 'https:' : 'http:'; let base = webBasePath ? webBasePath.replace(/^\//, '') : ''; if (base && !base.endsWith('/')) base += '/'; @@ -144,7 +149,7 @@ export default function SettingsPage() { onOk: async () => { setSpinning(true); try { - const msg = await HttpUtil.post('/panel/api/setting/restartPanel') as ApiMsg; + const msg = (await HttpUtil.post('/panel/api/setting/restartPanel')) as ApiMsg; if (!msg?.success) return; await PromiseUtil.sleep(5000); window.location.replace(rebuildUrlAfterRestart()); @@ -170,7 +175,11 @@ export default function SettingsPage() { if (allSetting.subEnable) { let subPath = allSetting.subPath; if (allSetting.subURI) { - try { subPath = new URL(allSetting.subURI).pathname; } catch { /* noop */ } + try { + subPath = new URL(allSetting.subURI).pathname; + } catch { + /* noop */ + } } if (subPath === '/sub/') { out.push(t('pages.settings.warnDefaultSubPath')); @@ -179,7 +188,11 @@ export default function SettingsPage() { if (allSetting.subJsonEnable) { let p = allSetting.subJsonPath; if (allSetting.subJsonURI) { - try { p = new URL(allSetting.subJsonURI).pathname; } catch { /* noop */ } + try { + p = new URL(allSetting.subJsonURI).pathname; + } catch { + /* noop */ + } } if (p === '/json/') { out.push(t('pages.settings.warnDefaultJsonPath')); @@ -197,12 +210,24 @@ export default function SettingsPage() { const categoryBody = useMemo(() => { switch (activeSlug) { - case 'security': return ; - case 'telegram': return ; - case 'email': return ; - case 'subscription': return ; - case 'subscription-formats': return ; - default: return ; + case 'security': + return ( + + ); + case 'telegram': + return ; + case 'email': + return ; + case 'subscription': + return ; + case 'subscription-formats': + return ; + default: + return ; } }, [activeSlug, allSetting, updateSetting, savePayload]); @@ -215,7 +240,12 @@ export default function SettingsPage() { - + {!fetched ? (
) : ( @@ -227,14 +257,16 @@ export default function SettingsPage() { closable={{ onClose: () => setAlertVisible(false) }} className="conf-alert" title={t('pages.settings.securityWarnings')} - description={( + description={ <> {t('pages.settings.panelExposed')}
    - {confAlerts.map((msg, i) =>
  • {msg}
  • )} + {confAlerts.map((msg, i) => ( +
  • {msg}
  • + ))}
- )} + } /> )} @@ -247,7 +279,12 @@ export default function SettingsPage() { - @@ -261,9 +298,7 @@ export default function SettingsPage() { - - {categoryBody} - + {categoryBody} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx index eebb759d5..fac2db332 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx @@ -1,13 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { - Card, - Input, - InputNumber, - Select, - Switch, - Tabs, -} from 'antd'; +import { Card, Input, InputNumber, Select, Switch, Tabs } from 'antd'; import { FileTextOutlined, NodeIndexOutlined, @@ -73,7 +66,10 @@ function readJson(raw: string, fallback: T): T { } } -export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) { +export default function SubscriptionFormatsTab({ + allSetting, + updateSetting, +}: SubscriptionFormatsTabProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); @@ -81,7 +77,8 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su const directEnabled = allSetting.subJsonRules !== ''; const muxObj = useMemo( - () => (muxEnabled ? readJson(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), + () => + muxEnabled ? readJson(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX, [allSetting.subJsonMux, muxEnabled], ); @@ -89,7 +86,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su updateSetting({ subJsonMux: v ? JSON.stringify(DEFAULT_MUX) : '' }); } - function setMuxField(key: K, value: typeof DEFAULT_MUX[K]) { + function setMuxField(key: K, value: (typeof DEFAULT_MUX)[K]) { const next = { ...muxObj, [key]: value }; updateSetting({ subJsonMux: JSON.stringify(next) }); } @@ -148,190 +145,267 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su } return ( - , t('pages.settings.panelSettings'), isMobile), - children: ( -
- {allSetting.subJsonEnable && ( - - - {t('pages.settings.subJsonEnableTitle')} - - )} + , t('pages.settings.panelSettings'), isMobile), + children: ( +
+ {allSetting.subJsonEnable && ( + + + {t('pages.settings.subJsonEnableTitle')} + + } + > + JSON {t('pages.settings.subPath')}} + description={t('pages.settings.subPathDesc')} + > + updateSetting({ subJsonPath: sanitizePath(e.target.value) })} + onBlur={() => + updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) }) + } + /> + + JSON {t('pages.settings.subURI')}} + description={t('pages.settings.subURIDesc')} + > + updateSetting({ subJsonURI: e.target.value })} + /> + + + updateSetting({ subJsonAlwaysArray: value })} + /> + + + updateSetting({ subJsonAutoDetect: v })} + /> + + + updateSetting({ subJsonUserAgentRegex: value })} + /> + + + )} + {allSetting.subClashEnable && ( + + + {t('pages.settings.subClashEnableTitle')} + + } + > + Clash {t('pages.settings.subPath')}} + description={t('pages.settings.subPathDesc')} + > + + updateSetting({ subClashPath: sanitizePath(e.target.value) }) + } + onBlur={() => + updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) }) + } + /> + + Clash {t('pages.settings.subURI')}} + description={t('pages.settings.subURIDesc')} + > + updateSetting({ subClashURI: e.target.value })} + /> + + + updateSetting({ subClashAutoDetect: v })} + /> + + + updateSetting({ subClashUserAgentRegex: value })} + /> + + + )} +
+ ), + }, + { + key: '2', + label: catTabLabel( + , + t('pages.settings.subFormats.finalMask'), + isMobile, + ), + children: ( + <> + + updateSetting({ subJsonFinalMask: v })} + /> + + ), + }, + { + key: '3', + label: catTabLabel(, t('pages.settings.mux'), isMobile), + children: ( + <> + - JSON {t('pages.settings.subPath')}} description={t('pages.settings.subPathDesc')}> - updateSetting({ subJsonPath: sanitizePath(e.target.value) })} - onBlur={() => updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })} - /> - - JSON {t('pages.settings.subURI')}} description={t('pages.settings.subURIDesc')}> - updateSetting({ subJsonURI: e.target.value })} - /> - - - updateSetting({ subJsonAlwaysArray: value })} /> - - - updateSetting({ subJsonAutoDetect: v })} /> - - - updateSetting({ subJsonUserAgentRegex: value })} - /> - -
- )} - {allSetting.subClashEnable && ( - - - {t('pages.settings.subClashEnableTitle')} - - )} + + + {muxEnabled && ( +
+ + setMuxField('concurrency', v))} + /> + + + setMuxField('xudpConcurrency', v))} + /> + + + updateSetting({ subClashPath: sanitizePath(e.target.value) })} - onBlur={() => updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })} - /> - - Clash {t('pages.settings.subURI')}} description={t('pages.settings.subURIDesc')}> - updateSetting({ subClashURI: e.target.value })} - /> - - - updateSetting({ subClashAutoDetect: v })} - /> - - - updateSetting({ subClashUserAgentRegex: value })} - /> - - - )} -
- ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.subFormats.finalMask'), isMobile), - children: ( - <> - - updateSetting({ subJsonFinalMask: v })} - /> - - ), - }, - { - key: '3', - label: catTabLabel(, t('pages.settings.mux'), isMobile), - children: ( - <> - - - - {muxEnabled && ( -
- - setMuxField('concurrency', v))} /> - - - setMuxField('xudpConcurrency', v))} /> - - - - - {t('pages.settings.direct')} {t('domainName')}}> - + + + {t('pages.settings.direct')} {t('domainName')} + + } + > + updateSetting({ subListen: e.target.value })} /> - - - updateSetting({ subDomain: e.target.value })} /> - - } description={t('pages.settings.subPortDesc')}> - updateSetting({ subPort: v }))} /> - - - updateSetting({ subPath: sanitizePath(e.target.value) })} - onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })} - /> - - - updateSetting({ subURI: e.target.value })} /> - - - ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.information'), isMobile), - children: ( - <> - - updateSetting({ subEncrypt: v })} /> - - - updateSetting({ remarkTemplate: v })} - maxLength={256} - /> - - - updateSetting({ subShowIdentityOnAllLinks: v })} - /> - - - } description={t('pages.settings.subUpdatesDesc')}> - updateSetting({ subUpdates: v }))} /> - - - ), - }, - { - key: '3', - label: catTabLabel(, t('pages.settings.profile'), isMobile), - children: ( - <> - - updateSetting({ subTitle: v })} - metadataOnly - /> - - - updateSetting({ subSupportUrl: v })} - metadataOnly - /> - - - updateSetting({ subProfileUrl: v })} - metadataOnly - /> - - - updateSetting({ subAnnounce: v })} - multiline - rows={3} - metadataOnly - /> - - - {t('pages.settings.subThemeDirDesc')}{' '} - - {t('pages.settings.subThemeDirDocs')} - - + , t('pages.settings.panelSettings'), isMobile), + children: ( + <> + + updateSetting({ subEnable: v })} + /> + + + updateSetting({ subJsonEnable: v })} + /> + + + updateSetting({ subClashEnable: v })} + /> + + {(allSetting.subJsonEnable || allSetting.subClashEnable) && ( + navigate('/settings#subscription-formats')}> + {t('pages.settings.subFormatsTipAction')} + + } + /> )} - > - updateSetting({ subThemeDir: e.target.value })} /> - - - ), - }, - { - key: '4', - label: catTabLabel(, t('pages.settings.certs'), isMobile), - children: ( - <> - - updateSetting({ subCertFile: e.target.value })} /> - - - updateSetting({ subKeyFile: e.target.value })} /> - - - ), - }, - { - key: '5', - label: catTabLabel(, 'Happ', isMobile), - children: ( - <> - - updateSetting({ subEnableRouting: v })} /> - - - updateSetting({ subRoutingRules: e.target.value })} /> - - - updateSetting({ subHideSettings: v })} /> - - - ), - }, - { - key: '6', - label: catTabLabel(, 'Clash / Mihomo', isMobile), - children: ( - <> - - updateSetting({ subClashEnableRouting: v })} /> - - - updateSetting({ subClashRules: e.target.value })} - /> - - - ), - }, - { - key: '7', - label: catTabLabel(, 'Incy', isMobile), - children: ( - <> - - updateSetting({ subIncyEnableRouting: v })} /> - - - updateSetting({ subIncyRoutingRules: e.target.value })} /> - - - ), - }, - ]} /> + + updateSetting({ subListen: e.target.value })} + /> + + + updateSetting({ subDomain: e.target.value })} + /> + + } + description={t('pages.settings.subPortDesc')} + > + updateSetting({ subPort: v }))} + /> + + + updateSetting({ subPath: sanitizePath(e.target.value) })} + onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })} + /> + + + updateSetting({ subURI: e.target.value })} + /> + + + ), + }, + { + key: '2', + label: catTabLabel(, t('pages.settings.information'), isMobile), + children: ( + <> + + updateSetting({ subEncrypt: v })} + /> + + + updateSetting({ remarkTemplate: v })} + maxLength={256} + /> + + + updateSetting({ subShowIdentityOnAllLinks: v })} + /> + + + } + description={t('pages.settings.subUpdatesDesc')} + > + updateSetting({ subUpdates: v }))} + /> + + + ), + }, + { + key: '3', + label: catTabLabel(, t('pages.settings.profile'), isMobile), + children: ( + <> + + updateSetting({ subTitle: v })} + metadataOnly + /> + + + updateSetting({ subSupportUrl: v })} + metadataOnly + /> + + + updateSetting({ subProfileUrl: v })} + metadataOnly + /> + + + updateSetting({ subAnnounce: v })} + multiline + rows={3} + metadataOnly + /> + + + {t('pages.settings.subThemeDirDesc')}{' '} + + {t('pages.settings.subThemeDirDocs')} + + + } + > + updateSetting({ subThemeDir: e.target.value })} + /> + + + ), + }, + { + key: '4', + label: catTabLabel(, t('pages.settings.certs'), isMobile), + children: ( + <> + + updateSetting({ subCertFile: e.target.value })} + /> + + + updateSetting({ subKeyFile: e.target.value })} + /> + + + ), + }, + { + key: '5', + label: catTabLabel(, 'Happ', isMobile), + children: ( + <> + + updateSetting({ subEnableRouting: v })} + /> + + + updateSetting({ subRoutingRules: e.target.value })} + /> + + + updateSetting({ subHideSettings: v })} + /> + + + ), + }, + { + key: '6', + label: catTabLabel(, 'Clash / Mihomo', isMobile), + children: ( + <> + + updateSetting({ subClashEnableRouting: v })} + /> + + + updateSetting({ subClashRules: e.target.value })} + /> + + + ), + }, + { + key: '7', + label: catTabLabel(, 'Incy', isMobile), + children: ( + <> + + updateSetting({ subIncyEnableRouting: v })} + /> + + + updateSetting({ subIncyRoutingRules: e.target.value })} + /> + + + ), + }, + ]} + /> ); } diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx index 6f3600886..66445f900 100644 --- a/frontend/src/pages/settings/TelegramTab.tsx +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -39,7 +39,12 @@ function parseRunTime(raw: string): RunTime { const v = (raw ?? '').trim(); const m = v.match(EVERY_RE); if (m) { - return { mode: 'every', num: Math.max(1, Number(m[1]) || 1), unit: m[2].toLowerCase() as Unit, custom: '' }; + return { + mode: 'every', + num: Math.max(1, Number(m[1]) || 1), + unit: m[2].toLowerCase() as Unit, + custom: '', + }; } if ((MACROS as string[]).includes(v)) { return { mode: v as Macro, num: 1, unit: 'h', custom: '' }; @@ -60,17 +65,22 @@ function composeRunTime(s: RunTime): string { // edit (and one that the 6-field parser accepts). function toCrontab(s: RunTime): string { switch (s.mode) { - case '@hourly': return '0 0 * * * *'; - case '@daily': return '0 0 0 * * *'; - case '@weekly': return '0 0 0 * * 0'; - case '@monthly': return '0 0 0 1 * *'; + case '@hourly': + return '0 0 * * * *'; + case '@daily': + return '0 0 0 * * *'; + case '@weekly': + return '0 0 0 * * 0'; + case '@monthly': + return '0 0 0 1 * *'; case 'every': { const n = Math.max(1, s.num || 1); if (s.unit === 's') return `*/${n} * * * * *`; if (s.unit === 'm') return `0 */${n} * * * *`; return `0 0 */${n} * * *`; } - default: return s.custom; + default: + return s.custom; } } @@ -160,106 +170,168 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr setTestLoading(true); setTestResult(null); try { - const res = await HttpUtil.post('/panel/api/setting/testTgBot') as { success?: boolean; msg?: string }; + const res = (await HttpUtil.post('/panel/api/setting/testTgBot')) as { + success?: boolean; + msg?: string; + }; setTestResult({ success: !!res.success, msg: res.msg || '' }); } catch (e: unknown) { - setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') }); + setTestResult({ + success: false, + msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'), + }); } finally { setTestLoading(false); } } const langOptions = useMemo( - () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({ - value: l.value, - label: ( - <> - {l.icon} -   {l.name} - + () => + LanguageManager.supportedLanguages.map( + (l: { value: string; name: string; icon: string }) => ({ + value: l.value, + label: ( + <> + + {l.icon} + +   {l.name} + + ), + }), ), - })), [], ); return ( - , t('pages.settings.panelSettings'), isMobile), - children: ( - <> - - updateSetting({ tgBotEnable: v })} /> - - - - updateSetting({ tgBotToken: v })} - onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })} - /> - - - - updateSetting({ tgBotChatId: e.target.value })} /> - - - - updateSetting({ tgBotAPIServer: e.target.value })} /> - - - - - {testResult && ( - setTestResult(null) }} + , t('pages.settings.panelSettings'), isMobile), + children: ( + <> + + updateSetting({ tgBotEnable: v })} /> - )} - - - ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.notifications'), isMobile), - children: ( - <> - - updateSetting({ tgRunTime: v })} /> - - - updateSetting({ tgBotBackup: v })} /> - + - - - - - ), - }, - ]} /> + + updateSetting({ tgBotToken: v })} + onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })} + /> + + + + updateSetting({ tgBotChatId: e.target.value })} + /> + + + + updateSetting({ tgBotAPIServer: e.target.value })} + /> + + + + + {testResult && ( + setTestResult(null) }} + /> + )} + + + ), + }, + { + key: '2', + label: catTabLabel(, t('pages.settings.notifications'), isMobile), + children: ( + <> + + updateSetting({ tgRunTime: v })} + /> + + + updateSetting({ tgBotBackup: v })} + /> + + + + + + + ), + }, + ]} + /> ); } diff --git a/frontend/src/pages/settings/TwoFactorModal.tsx b/frontend/src/pages/settings/TwoFactorModal.tsx index f20eb1611..567de0900 100644 --- a/frontend/src/pages/settings/TwoFactorModal.tsx +++ b/frontend/src/pages/settings/TwoFactorModal.tsx @@ -37,7 +37,7 @@ export default function TwoFactorModal({ useEffect(() => { if (!open) return; - + setEnteredCode(''); totpRef.current = null; setQrValue(''); @@ -53,7 +53,6 @@ export default function TwoFactorModal({ totpRef.current = totp; setQrValue(totp.toString()); } - }, [open, token]); function close(success: boolean, code = '') { @@ -65,7 +64,9 @@ export default function TwoFactorModal({ function onOk() { const codeOk = TotpCodeSchema.safeParse(enteredCode); if (!codeOk.success) { - messageApi.error(t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError')); + messageApi.error( + t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'), + ); return; } if (type === 'confirm' && !token) { @@ -97,49 +98,66 @@ export default function TwoFactorModal({ title={title} closable onCancel={onCancel} - footer={[ - , - , - ]} - > - {type === 'set' ? ( - <> -

{t('pages.settings.security.twoFactorModalSteps')}

- -

{t('pages.settings.security.twoFactorModalFirstStep')}

-
+ {t('cancel')} + , +
- -

{t('pages.settings.security.twoFactorModalSecondStep')}

- setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} /> - - ) : ( - <> -

{description}

- setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} /> - - )} + + ) : ( + <> +

{description}

+ setEnteredCode(e.target.value)} + style={{ width: '100%' }} + aria-label={t('twoFactorCode')} + /> + + )} ); diff --git a/frontend/src/pages/settings/catTabLabel.tsx b/frontend/src/pages/settings/catTabLabel.tsx index be1cb2046..ddfab6fd3 100644 --- a/frontend/src/pages/settings/catTabLabel.tsx +++ b/frontend/src/pages/settings/catTabLabel.tsx @@ -6,9 +6,10 @@ import { Tooltip } from 'antd'; old top tab bar's icons-only behaviour. */ export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode { if (iconsOnly) { - const labelledIcon = typeof text === 'string' && isValidElement(icon) - ? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text }) - : icon; + const labelledIcon = + typeof text === 'string' && isValidElement(icon) + ? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text }) + : icon; return {labelledIcon}; } return ( diff --git a/frontend/src/pages/settings/uriPath.ts b/frontend/src/pages/settings/uriPath.ts index 88c55bc1a..3b7e66784 100644 --- a/frontend/src/pages/settings/uriPath.ts +++ b/frontend/src/pages/settings/uriPath.ts @@ -2,7 +2,8 @@ export function sanitizePath(input: string): string { let out = ''; for (const ch of String(input ?? '')) { const code = ch.charCodeAt(0); - if (ch === ':' || ch === '*' || ch === ' ' || ch === '\\' || code < 0x20 || code === 0x7f) continue; + if (ch === ':' || ch === '*' || ch === ' ' || ch === '\\' || code < 0x20 || code === 0x7f) + continue; out += ch; } return out; diff --git a/frontend/src/pages/sub/SubPage.css b/frontend/src/pages/sub/SubPage.css index d3765c7f4..bd0072bd8 100644 --- a/frontend/src/pages/sub/SubPage.css +++ b/frontend/src/pages/sub/SubPage.css @@ -61,7 +61,9 @@ border-radius: 10px; background: rgba(0, 0, 0, 0.03); border: 1px solid rgba(0, 0, 0, 0.08); - transition: background 120ms ease, border-color 120ms ease; + transition: + background 120ms ease, + border-color 120ms ease; } .sub-link-row:hover { @@ -127,4 +129,3 @@ .toolbar-btn .anticon { font-size: 18px; } - diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx index 82f981f0d..650512d06 100644 --- a/frontend/src/pages/sub/SubPage.tsx +++ b/frontend/src/pages/sub/SubPage.tsx @@ -72,8 +72,9 @@ const isUnlimited = totalByte <= 0 && expireMs === 0; const isActive = (() => { if (!enabled) return false; if (totalByte > 0) { - const usedByteCalc = Number(subData.usedByte || 0) - || (Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)); + const usedByteCalc = + Number(subData.usedByte || 0) || + Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0); if (usedByteCalc >= totalByte) return false; } if (expireMs > 0 && Date.now() >= expireMs) return false; @@ -84,7 +85,9 @@ export default function SubPage() { const { t } = useTranslation(); const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const [messageApi, messageContextHolder] = message.useMessage(); - useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); + useEffect(() => { + setMessageInstance(messageApi); + }, [messageApi]); const { isMobile } = useMediaQuery(576); const [lang, setLang] = useState(() => LanguageManager.getLanguage()); @@ -106,11 +109,14 @@ export default function SubPage() { } }, [isDark, isUltra, toggleTheme, toggleUltra]); - const copy = useCallback(async (value: string) => { - if (!value) return; - const ok = await ClipboardManager.copyText(value); - if (ok) messageApi.success(t('copied')); - }, [t, messageApi]); + const copy = useCallback( + async (value: string) => { + if (!value) return; + const ok = await ClipboardManager.copyText(value); + if (ok) messageApi.success(t('copied')); + }, + [t, messageApi], + ); const copyAll = useCallback(async () => { if (links.length === 0) return; @@ -155,13 +161,15 @@ export default function SubPage() { { key: 'status', label: t('subscription.status'), - children: !enabled - ? {t('subscription.inactive')} - : isUnlimited - ? {t('subscription.unlimited')} - : - {isActive ? t('subscription.active') : t('subscription.inactive')} - , + children: !enabled ? ( + {t('subscription.inactive')} + ) : isUnlimited ? ( + {t('subscription.unlimited')} + ) : ( + + {isActive ? t('subscription.active') : t('subscription.inactive')} + + ), }, { key: 'down', label: t('subscription.downloaded'), children: download }, { key: 'up', label: t('subscription.uploaded'), children: upload }, @@ -179,51 +187,62 @@ export default function SubPage() { items.push({ key: 'expiry', label: t('subscription.expiry'), - children: expireMs === 0 - ? t('subscription.noExpiry') - : IntlUtil.formatDate(expireMs, datepicker), + children: + expireMs === 0 ? t('subscription.noExpiry') : IntlUtil.formatDate(expireMs, datepicker), }); return items; }, [t]); - const androidMenuItems = useMemo(() => [ - { - key: 'android-v2box', - label: 'V2Box', - onClick: () => open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`), - }, - { - key: 'android-v2rayng', - label: 'V2RayNG', - onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`), - }, - { key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) }, - { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, - { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, - { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) }, - { key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) }, - ], [copy, open]); + const androidMenuItems = useMemo( + () => [ + { + key: 'android-v2box', + label: 'V2Box', + onClick: () => + open( + `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`, + ), + }, + { + key: 'android-v2rayng', + label: 'V2RayNG', + onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`), + }, + { key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) }, + { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, + { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, + { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) }, + { key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) }, + ], + [copy, open], + ); - const iosMenuItems = useMemo(() => [ - { key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) }, - { key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) }, - { key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) }, - { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, - { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, - { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) }, - { key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) }, - ], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl]); + const iosMenuItems = useMemo( + () => [ + { key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) }, + { key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) }, + { key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) }, + { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, + { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, + { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) }, + { key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) }, + ], + [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl], + ); const langMenuItems = useMemo( - () => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({ - key: l.value, - label: ( - - - {l.name} - + () => + (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map( + (l) => ({ + key: l.value, + label: ( + + + {l.name} + + ), + }), ), - })), [], ); @@ -294,8 +313,10 @@ export default function SubPage() { /> {subUrl && (
)} {subJsonUrl && ( } > -
@@ -380,7 +445,9 @@ export default function SubPage() { {subClashUrl && ( } > -
@@ -444,62 +531,65 @@ export default function SubPage() { const rowTitle = parts?.remark || fallback; const qrLabel = parts?.remark || rowTitle; const canQr = !isPostQuantumLink(link); - const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://'); + const isWireguardLink = + link.startsWith('wireguard://') || link.startsWith('wg://'); return ( -
- {parts - ? - : LINK} - - {rowTitle} - -
-
- } - > -
+ } + > +
-
- {isWireguardLink && ( - - )} + {isWireguardLink && ( + + )} ); })} diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index 36d605228..1a4fe6b2d 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -30,7 +30,11 @@ import { propagateOutboundTagRename } from './basics/helpers'; import { RoutingTab } from './routing'; import { OutboundsTab } from './outbounds'; import { BalancersTab } from './balancers'; -import { cleanupOrphanedBalancerLoopbacks, ensureMissingBalancerLoopbacks, detectBalancerCycles } from './balancers/balancer-loopback'; +import { + cleanupOrphanedBalancerLoopbacks, + ensureMissingBalancerLoopbacks, + detectBalancerCycles, +} from './balancers/balancer-loopback'; import { DnsTab } from './dns'; import { WarpModal, NordModal } from './overrides'; import './XrayPage.css'; @@ -44,7 +48,9 @@ export default function XrayPage() { const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isMobile } = useMediaQuery(); const [messageApi, messageContextHolder] = message.useMessage(); - useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); + useEffect(() => { + setMessageInstance(messageApi); + }, [messageApi]); const xs = useXraySetting(); const { fetched, @@ -79,7 +85,12 @@ export default function XrayPage() { const [advSettings, setAdvSettings] = useState('xraySetting'); const location = useLocation(); const navigate = useNavigate(); - const pathSection = location.pathname === '/outbound' ? 'outbound' : location.pathname === '/routing' ? 'routing' : ''; + const pathSection = + location.pathname === '/outbound' + ? 'outbound' + : location.pathname === '/routing' + ? 'routing' + : ''; const sectionSlug = pathSection || location.hash.replace(/^#/, ''); const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic'; @@ -111,7 +122,12 @@ export default function XrayPage() { tt.outbounds.push(outbound as never); }); } - function onResetOutbound(payload: { index: number; outbound: Record; oldTag?: string; newTag?: string }) { + function onResetOutbound(payload: { + index: number; + outbound: Record; + oldTag?: string; + newTag?: string; + }) { mutate((tt) => { if (!tt.outbounds || payload.index < 0) return; tt.outbounds[payload.index] = payload.outbound as never; @@ -146,10 +162,14 @@ export default function XrayPage() { if (!tpl) return ''; try { switch (advSettings) { - case 'inboundSettings': return JSON.stringify(tpl.inbounds || [], null, 2); - case 'outboundSettings': return JSON.stringify(tpl.outbounds || [], null, 2); - case 'routingRuleSettings': return JSON.stringify(tpl.routing?.rules || [], null, 2); - default: return ''; + case 'inboundSettings': + return JSON.stringify(tpl.inbounds || [], null, 2); + case 'outboundSettings': + return JSON.stringify(tpl.outbounds || [], null, 2); + case 'routingRuleSettings': + return JSON.stringify(tpl.routing?.rules || [], null, 2); + default: + return ''; } } catch { return ''; @@ -259,10 +279,7 @@ export default function XrayPage() { ); case 'dns': return ( - + ); case 'advanced': return ( @@ -312,7 +329,12 @@ export default function XrayPage() { - + {!fetched ? (
) : fetchError ? ( @@ -320,7 +342,11 @@ export default function XrayPage() { status="error" title={t('somethingWentWrong')} subTitle={fetchError} - extra={} + extra={ + + } /> ) : ( @@ -343,9 +369,7 @@ export default function XrayPage() { - - {sectionBody} - + {sectionBody} )} diff --git a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx index 780e6f0e7..c91df29e4 100644 --- a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx +++ b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx @@ -9,10 +9,7 @@ import type { Path } from 'react-hook-form'; import { InputAddon } from '@/components/ui'; import { FormField } from '@/components/form/rhf'; import type { XraySettingsValue } from '@/hooks/useXraySetting'; -import { - BalancerFormSchema, - type BalancerFormValues, -} from '@/schemas/xray'; +import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray'; import { BalancerStrategyTypeSchema, type BalancerStrategyType, @@ -95,7 +92,10 @@ export default function BalancerFormModal({ ); const cycleInfo = useMemo(() => { - const rules = (templateSettings?.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>; + const rules = (templateSettings?.routing?.rules || []) as Array<{ + inboundTag?: string[]; + balancerTag?: string; + }>; const resolveLoopback = (tag: string): string | null => { for (const r of rules) { if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) { @@ -135,9 +135,8 @@ export default function BalancerFormModal({ const wouldCreateCycle = !!cycleInfo[fallbackTag]; const fallbackOptions = useMemo(() => { - const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> = [ - { value: '', label: `(${t('none')})` }, - ]; + const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> = + [{ value: '', label: `(${t('none')})` }]; for (const tg of outboundTags) { options.push({ value: tg, label: tg }); } @@ -146,10 +145,14 @@ export default function BalancerFormModal({ options.push({ value: tg, disabled: !!cycle, - title: cycle ? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag }) : undefined, + title: cycle + ? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag }) + : undefined, label: ( - {t('pages.xray.rules.balancer')} + + {t('pages.xray.rules.balancer')} + {tg} ), @@ -215,13 +218,16 @@ export default function BalancerFormModal({ const errorMessage = fieldState.error?.message ? t(fieldState.error.message, { defaultValue: fieldState.error.message }) : ''; - const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate; + const showDuplicate = + !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate; return ( v ?? '', output: (v) => (typeof v === 'string' && v ? v : undefined) }} + transform={{ + input: (v) => v ?? '', + output: (v) => (typeof v === 'string' && v ? v : undefined), + }} > @@ -295,7 +304,13 @@ export default function BalancerFormModal({ label={t('pages.xray.balancer.tolerance')} transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }} > - + - - ) : ( - - - {hostsList.map((row, idx) => ( -
- { - const next = hostsList.map((r, i) => (i === idx ? { ...r, domain: e.target.value } : r)); - syncHosts(next); - }} - /> - { + const next = hostsList.map((r, i) => + i === idx ? { ...r, domain: e.target.value } : r, + ); + syncHosts(next); + }} + /> + - + @@ -518,7 +523,9 @@ export default function OutboundFormModal({ buttonStyle="solid" onChange={(e) => onSecurityChange(e.target.value as string)} > - {network !== 'hysteria' && {t('none')}} + {network !== 'hysteria' && ( + {t('none')} + )} {tlsAllowed && TLS} {realityAllowed && Reality} @@ -529,7 +536,9 @@ export default function OutboundFormModal({ {security === 'reality' && realityAllowed && } - {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && ( + {((streamAllowed && network) || + !streamAllowed || + protocol === 'wireguard') && ( )} @@ -555,7 +564,11 @@ export default function OutboundFormModal({ key: '2', label: 'JSON', children: ( - + ([]); const [subsLoading, setSubsLoading] = useState(false); - const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false }); + const [newSub, setNewSub] = useState({ + remark: '', + url: '', + tagPrefix: '', + updateInterval: 600, + enabled: true, + allowPrivate: false, + allowInsecure: false, + prepend: false, + }); const [editingSubId, setEditingSubId] = useState(null); const [savingSub, setSavingSub] = useState(false); const [refreshingId, setRefreshingId] = useState(null); const [refreshingAll, setRefreshingAll] = useState(false); const [busyId, setBusyId] = useState(null); const [previewing, setPreviewing] = useState(false); - const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null); + 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); @@ -184,7 +201,9 @@ export default function OutboundsTab({ function openAdd() { setEditingOutbound(null); setEditingIndex(null); - setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg)); + setExistingTags( + (templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg), + ); setModalOpen(true); } @@ -281,7 +300,11 @@ export default function OutboundsTab({ return; } const obj = parsed as { outbounds?: unknown }; - const list = Array.isArray(parsed) ? parsed : Array.isArray(obj?.outbounds) ? obj.outbounds : null; + const list = Array.isArray(parsed) + ? parsed + : Array.isArray(obj?.outbounds) + ? obj.outbounds + : null; if (!list) { messageApi.error(t('pages.xray.importInvalidJson')); return; @@ -305,7 +328,16 @@ export default function OutboundsTab({ setSubsLoading(false); } } - function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; allowInsecure?: boolean; prepend?: boolean }) { + 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 ?? '', @@ -318,7 +350,16 @@ export default function OutboundsTab({ }; } function resetSubForm() { - setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false }); + setNewSub({ + remark: '', + url: '', + tagPrefix: '', + updateInterval: 600, + enabled: true, + allowPrivate: false, + allowInsecure: false, + prepend: false, + }); setEditingSubId(null); setPreviewData(null); } @@ -343,12 +384,19 @@ export default function OutboundsTab({ } setSavingSub(true); try { - const url = editingSubId != null - ? `/panel/api/xray/outbound-subs/${editingSubId}` - : '/panel/api/xray/outbound-subs'; + const url = + editingSubId != null + ? `/panel/api/xray/outbound-subs/${editingSubId}` + : '/panel/api/xray/outbound-subs'; const r = await HttpUtil.post(url, subBody(newSub)); if (r?.success) { - messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded')); + messageApi.success( + t( + editingSubId != null + ? 'pages.xray.outboundSub.toastUpdated' + : 'pages.xray.outboundSub.toastAdded', + ), + ); const createdId = editingSubId == null ? r.obj?.id : undefined; resetSubForm(); await loadSubs(); @@ -371,7 +419,10 @@ export default function OutboundsTab({ 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 }); + 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')); @@ -387,7 +438,10 @@ export default function OutboundsTab({ 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 })); + const r = await HttpUtil.post( + `/panel/api/xray/outbound-subs/${sub.id}`, + subBody({ ...sub, enabled: !sub.enabled }), + ); if (r?.success) { await loadSubs(); onRefreshXrayData?.(); @@ -436,7 +490,11 @@ export default function OutboundsTab({ setRefreshingAll(true); try { for (const s of subs) { - try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ } + try { + await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); + } catch { + /* continue */ + } } messageApi.success(t('pages.xray.outboundSub.toastRefreshed')); await loadSubs(); @@ -493,8 +551,19 @@ export default function OutboundsTab({ { key: 'warp', icon: , label: 'WARP', onClick: onShowWarp }, { key: 'nord', icon: , label: 'NordVPN', onClick: onShowNord }, { type: 'divider' }, - { key: 'import', icon: , label: t('pages.xray.importOutbounds'), onClick: () => setImportOpen(true) }, - { key: 'export', icon: , label: t('pages.xray.exportOutbounds'), disabled: outbounds.length === 0, onClick: exportOutbounds }, + { + key: 'import', + icon: , + label: t('pages.xray.importOutbounds'), + onClick: () => setImportOpen(true), + }, + { + key: 'export', + icon: , + label: t('pages.xray.exportOutbounds'), + disabled: outbounds.length === 0, + onClick: exportOutbounds, + }, ], }} > @@ -505,13 +574,23 @@ export default function OutboundsTab({ - setTestMode(e.target.value)} buttonStyle="solid" size="small"> + setTestMode(e.target.value)} + buttonStyle="solid" + size="small" + > TCP HTTP {t('pages.xray.outbound.modeRealDelay')} - - setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} /> + setNewSub({ ...newSub, remark: e.target.value })} + placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} + /> - setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} /> + setNewSub({ ...newSub, url: e.target.value })} + placeholder={t('pages.xray.outboundSub.urlPlaceholder')} + /> - setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} /> + setNewSub({ ...newSub, tagPrefix: e.target.value })} + placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} + /> @@ -629,42 +720,61 @@ export default function OutboundsTab({ value={intervalHours} onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))} style={{ width: 80 }} - /> {t('pages.xray.outboundSub.hours')} + />{' '} + {t('pages.xray.outboundSub.hours')} setIntervalHM(intervalHours, v))} style={{ width: 80 }} - /> {t('pages.xray.outboundSub.minutes')} + />{' '} + {t('pages.xray.outboundSub.minutes')}
{t('pages.xray.outboundSub.intervalHint')}
- setNewSub({ ...newSub, enabled: v })} /> + setNewSub({ ...newSub, enabled: v })} + /> - setNewSub({ ...newSub, allowPrivate: v })} /> + setNewSub({ ...newSub, allowPrivate: v })} + />
{t('pages.xray.outboundSub.allowPrivateHint')}
- setNewSub({ ...newSub, allowInsecure: v })} /> + setNewSub({ ...newSub, allowInsecure: v })} + />
{t('pages.hosts.hints.allowInsecure')}
- setNewSub({ ...newSub, prepend: v })} /> + setNewSub({ ...newSub, prepend: v })} + />
{t('pages.xray.outboundSub.prependHint')}
- )} @@ -711,8 +854,22 @@ export default function OutboundsTab({ width: 56, render: (_: unknown, r: OutboundSub, index: number) => ( - - - {t('pages.xray.warp.settings')} - -
- v ?? undefined }} - onAfterChange={(v) => fetchServers(v as number)} - > - ({ value: c.id, label: c.name }))]} - /> - - )} - - {filteredServers.length > 0 && ( - - + + +
+ ), + }, + { + key: 'key', + label: t('pages.xray.nord.privateKey'), + children: ( +
+ + + + +
+ ), + }, + ]} + /> ) : ( <> - {t('disabled')} - + + {t('pages.xray.warp.settings')} + +
+ v ?? undefined }} + onAfterChange={(v) => fetchServers(v as number)} + > + ({ value: c.id, label: c.name })), + ]} + /> + + )} + + {filteredServers.length > 0 && ( + + - -
- - {licenseError && ( - - )} -
-
- ), - }, - { - key: '2', - label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'), - children: ( -
- Number(v) }} + + +
+ + {licenseError && ( + + )} +
+
+ ), + }, + { + key: '2', + label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'), + children: ( +
- - - -
- ), - }, - ]} - /> + Number(v) }} + > + + + + + ), + }, + ]} + /> - {t('pages.xray.warp.accountInfo')} -
- - -
+ {t('pages.xray.warp.accountInfo')} +
+ + +
- {hasConfig && ( - <> - - - - - - - - - - - - - - - {warpConfig?.account && ( - <> - - - - - - - - - - - - - - - - - {warpConfig.account.usage != null && ( + {hasConfig && ( + <> +
{t('pages.xray.warp.deviceName')}{warpConfig?.name}
{t('pages.xray.warp.deviceModel')}{warpConfig?.model}
{t('pages.xray.warp.deviceEnabled')}{String(warpConfig?.enabled)}
{t('pages.xray.warp.accountType')}{warpConfig.account.account_type}
{t('pages.xray.warp.role')}{warpConfig.account.role}
{t('pages.xray.warp.warpPlusData')}{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}
{t('pages.xray.warp.quota')}{SizeFormatter.sizeFormat(warpConfig.account.quota)}
+ + + + + + + + + + + + + + {warpConfig?.account && ( + <> - - + + - )} - - )} - -
{t('pages.xray.warp.deviceName')}{warpConfig?.name}
{t('pages.xray.warp.deviceModel')}{warpConfig?.model}
{t('pages.xray.warp.deviceEnabled')}{String(warpConfig?.enabled)}
{t('pages.xray.warp.usage')}{SizeFormatter.sizeFormat(warpConfig.account.usage)}{t('pages.xray.warp.accountType')}{warpConfig.account.account_type}
+ + {t('pages.xray.warp.role')} + {warpConfig.account.role} + + + {t('pages.xray.warp.warpPlusData')} + {SizeFormatter.sizeFormat(warpConfig.account.premium_data)} + + + {t('pages.xray.warp.quota')} + {SizeFormatter.sizeFormat(warpConfig.account.quota)} + + {warpConfig.account.usage != null && ( + + {t('pages.xray.warp.usage')} + {SizeFormatter.sizeFormat(warpConfig.account.usage)} + + )} + + )} + + - {t('pages.xray.outbound.outboundStatus')} - {warpOutboundIndex >= 0 ? ( - <> - {t('enabled')} - - - ) : ( - <> - {t('disabled')} - - - )} - - )} - - )} + {t('pages.xray.outbound.outboundStatus')} + {warpOutboundIndex >= 0 ? ( + <> + {t('enabled')} + + + ) : ( + <> + {t('disabled')} + + + )} + + )} + + )} diff --git a/frontend/src/pages/xray/reference-cleanup.ts b/frontend/src/pages/xray/reference-cleanup.ts index 25991ae32..ea5d6c6e4 100644 --- a/frontend/src/pages/xray/reference-cleanup.ts +++ b/frontend/src/pages/xray/reference-cleanup.ts @@ -36,7 +36,12 @@ export interface DeletionImpact { burst: boolean; } -const emptyImpact = (): DeletionImpact => ({ rules: [], balancers: [], observatory: false, burst: false }); +const emptyImpact = (): DeletionImpact => ({ + rules: [], + balancers: [], + observatory: false, + burst: false, +}); function ruleList(tt: XraySettingsValue): RuleObject[] { const r = tt.routing?.rules; @@ -159,7 +164,11 @@ function applyCleanup( for (const outbound of tt.outbounds) { const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } }) ?.streamSettings?.sockopt; - if (sockopt && typeof sockopt.dialerProxy === 'string' && removedOutbounds.has(sockopt.dialerProxy)) { + if ( + sockopt && + typeof sockopt.dialerProxy === 'string' && + removedOutbounds.has(sockopt.dialerProxy) + ) { delete sockopt.dialerProxy; } } diff --git a/frontend/src/pages/xray/routing/CriterionRow.tsx b/frontend/src/pages/xray/routing/CriterionRow.tsx index 78605a4f8..a167af173 100644 --- a/frontend/src/pages/xray/routing/CriterionRow.tsx +++ b/frontend/src/pages/xray/routing/CriterionRow.tsx @@ -2,7 +2,17 @@ import { Tooltip } from 'antd'; import { csv } from './helpers'; -export default function CriterionRow({ label, value, values, title }: { label: string; value?: string; values?: string[]; title: string }) { +export default function CriterionRow({ + label, + value, + values, + title, +}: { + label: string; + value?: string; + values?: string[]; + title: string; +}) { const parts = values ?? csv(value); if (parts.length === 0) return null; return ( diff --git a/frontend/src/pages/xray/routing/RouteTester.tsx b/frontend/src/pages/xray/routing/RouteTester.tsx index 4ccdfe7b0..703ce50ad 100644 --- a/frontend/src/pages/xray/routing/RouteTester.tsx +++ b/frontend/src/pages/xray/routing/RouteTester.tsx @@ -105,7 +105,9 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) allowClear value={inboundTag} onChange={setInboundTag} - options={inboundTags.filter(Boolean).map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))} + options={inboundTags + .filter(Boolean) + .map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))} /> @@ -120,14 +122,21 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) /> - - {result && ( - result.matched ? ( + {result && + (result.matched ? ( {t('pages.xray.routeTesterViaBalancer')}: {(result.groupTags || []).map((tag) => ( - {tag} + + {tag} + ))} )} @@ -148,8 +159,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) /> ) : ( - ) - )} + ))}
); } diff --git a/frontend/src/pages/xray/routing/RoutingBasic.tsx b/frontend/src/pages/xray/routing/RoutingBasic.tsx index bd0d44e3d..e8bba5213 100644 --- a/frontend/src/pages/xray/routing/RoutingBasic.tsx +++ b/frontend/src/pages/xray/routing/RoutingBasic.tsx @@ -13,7 +13,13 @@ import { directSettings, ipv4Settings, } from '../basics/constants'; -import { getDefaultOutboundTag, ruleGetter, ruleSetter, setDefaultOutboundTag, syncOutbound } from '../basics/helpers'; +import { + getDefaultOutboundTag, + ruleGetter, + ruleSetter, + setDefaultOutboundTag, + syncOutbound, +} from '../basics/helpers'; interface RoutingBasicProps { templateSettings: XraySettingsValue | null; @@ -81,12 +87,14 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }: control={ mutate((tt) => { - const next = checked - ? [...blockedProtocols, ...BITTORRENT_PROTOCOLS] - : blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d)); - ruleSetter(tt, 'blocked', 'protocol', next); - })} + onChange={(checked) => + mutate((tt) => { + const next = checked + ? [...blockedProtocols, ...BITTORRENT_PROTOCOLS] + : blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d)); + ruleSetter(tt, 'blocked', 'protocol', next); + }) + } /> } /> @@ -135,10 +143,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }: value={directIPs} style={{ width: '100%' }} options={IPS_OPTIONS} - onChange={(v) => mutate((tt) => { - ruleSetter(tt, 'direct', 'ip', v); - syncOutbound(tt, 'direct', directSettings); - })} + onChange={(v) => + mutate((tt) => { + ruleSetter(tt, 'direct', 'ip', v); + syncOutbound(tt, 'direct', directSettings); + }) + } /> } /> @@ -152,10 +162,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }: value={directDomains} style={{ width: '100%' }} options={DOMAINS_OPTIONS} - onChange={(v) => mutate((tt) => { - ruleSetter(tt, 'direct', 'domain', v); - syncOutbound(tt, 'direct', directSettings); - })} + onChange={(v) => + mutate((tt) => { + ruleSetter(tt, 'direct', 'domain', v); + syncOutbound(tt, 'direct', directSettings); + }) + } /> } /> @@ -170,10 +182,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }: value={ipv4Domains} style={{ width: '100%' }} options={SERVICES_OPTIONS} - onChange={(v) => mutate((tt) => { - ruleSetter(tt, 'IPv4', 'domain', v); - syncOutbound(tt, 'IPv4', ipv4Settings); - })} + onChange={(v) => + mutate((tt) => { + ruleSetter(tt, 'IPv4', 'domain', v); + syncOutbound(tt, 'IPv4', ipv4Settings); + }) + } /> } /> diff --git a/frontend/src/pages/xray/routing/RoutingTab.css b/frontend/src/pages/xray/routing/RoutingTab.css index 33176d793..d920bafe1 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.css +++ b/frontend/src/pages/xray/routing/RoutingTab.css @@ -112,7 +112,9 @@ border: 1px solid var(--ant-color-border); border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - transition: opacity 0.15s, box-shadow 0.15s; + transition: + opacity 0.15s, + box-shadow 0.15s; } .rule-list > .rule-card:not(:last-child)::after { diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx index 1b54a6213..b79865993 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ b/frontend/src/pages/xray/routing/RoutingTab.tsx @@ -51,7 +51,12 @@ export default function RoutingTab({ const [editingIndex, setEditingIndex] = useState(null); const [draggedIndex, setDraggedIndex] = useState(null); const [dropTargetIndex, setDropTargetIndex] = useState(null); - const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({ + const dragRef = useRef<{ + from: number | null; + to: number | null; + startY: number; + moved: boolean; + }>({ from: null, to: null, startY: 0, @@ -120,11 +125,15 @@ export default function RoutingTab({ for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag); for (const tag of inboundTags || []) push(tag); for (const ob of templateSettings?.outbounds || []) { - const obx = ob as { reverse?: { tag?: string }; settings?: { reverse?: { tag?: string }; inboundTag?: string } }; + const obx = ob as { + reverse?: { tag?: string }; + settings?: { reverse?: { tag?: string }; inboundTag?: string }; + }; push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag); } push((templateSettings?.dns as { tag?: string } | undefined)?.tag); - for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)?.servers || []) { + for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined) + ?.servers || []) { if (typeof s === 'object' && s?.tag) push(s.tag); } return out; @@ -222,9 +231,10 @@ export default function RoutingTab({ okText: t('delete'), okType: 'danger', cancelText: t('cancel'), - onOk: () => mutate((tt) => { - tt.routing?.rules?.splice(target, 1); - }), + onOk: () => + mutate((tt) => { + tt.routing?.rules?.splice(target, 1); + }), }); } @@ -262,7 +272,9 @@ export default function RoutingTab({ ev.preventDefault(); try { (ev.currentTarget as Element).setPointerCapture(ev.pointerId); - } catch { /* ignore */ } + } catch { + /* ignore */ + } dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false }; setDraggedIndex(idx); setDropTargetIndex(idx); @@ -357,8 +369,19 @@ export default function RoutingTab({ trigger={['click']} menu={{ items: [ - { key: 'import', icon: , label: t('pages.xray.importRules'), onClick: () => setImportOpen(true) }, - { key: 'export', icon: , label: t('pages.xray.exportRules'), disabled: rules.length === 0, onClick: exportRules }, + { + key: 'import', + icon: , + label: t('pages.xray.importRules'), + onClick: () => setImportOpen(true), + }, + { + key: 'export', + icon: , + label: t('pages.xray.exportRules'), + disabled: rules.length === 0, + onClick: exportRules, + }, ], }} > @@ -394,7 +417,10 @@ export default function RoutingTab({ if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) { classes.push(i > draggedIndex ? 'drop-after' : 'drop-before'); } - return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes; + return { + className: classes.join(' '), + 'data-row-key': i, + } as React.HTMLAttributes; }} /> )} diff --git a/frontend/src/pages/xray/routing/RuleCardList.tsx b/frontend/src/pages/xray/routing/RuleCardList.tsx index f63e3d58a..3b2f40107 100644 --- a/frontend/src/pages/xray/routing/RuleCardList.tsx +++ b/frontend/src/pages/xray/routing/RuleCardList.tsx @@ -13,7 +13,14 @@ import { } from '@ant-design/icons'; import { useInboundOptions } from '@/api/queries/useInboundOptions'; -import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, isApiRule, ruleCriteriaChips } from './helpers'; +import { + buildRemarkByTag, + chipPreview, + inboundTagChipPreview, + inboundTagsDisplayTitle, + isApiRule, + ruleCriteriaChips, +} from './helpers'; import type { RuleRow } from './types'; interface RuleCardListProps { @@ -51,7 +58,9 @@ export default function RuleCardList({
draggedIndex ? 'drop-after' : ''} ${ rule.enabled === false ? 'rule-disabled' : '' }`} @@ -68,14 +77,54 @@ export default function RuleCardList({ trigger={['click']} menu={{ items: [ - { key: 'edit', label: <> {t('edit')}, onClick: () => openEdit(index) }, - { key: 'up', label: <> {t('pages.inbounds.form.moveUp')}, disabled: index === 0, onClick: () => moveUp(index) }, - { key: 'down', label: <> {t('pages.inbounds.form.moveDown')}, disabled: index === rows.length - 1, onClick: () => moveDown(index) }, - { key: 'del', danger: true, label: <> {t('delete')}, onClick: () => confirmDelete(index) }, + { + key: 'edit', + label: ( + <> + {t('edit')} + + ), + onClick: () => openEdit(index), + }, + { + key: 'up', + label: ( + <> + {t('pages.inbounds.form.moveUp')} + + ), + disabled: index === 0, + onClick: () => moveUp(index), + }, + { + key: 'down', + label: ( + <> + {t('pages.inbounds.form.moveDown')} + + ), + disabled: index === rows.length - 1, + onClick: () => moveDown(index), + }, + { + key: 'del', + danger: true, + label: ( + <> + {t('delete')} + + ), + onClick: () => confirmDelete(index), + }, ], }} > -