Files
3x-ui/frontend
PathGao 87ebcc7a6f feat(ui): tag settings that sit at their shipped default value (#6128)
* feat(ui): tag settings that sit at their shipped default value

A field showing 2096 reads identically whether the install never set
it or the operator saved 2096 — newcomers cannot tell which knobs
they have touched, and after the cleared-port fix (#6121) a port can
never visually return to an unset state. Add a small grey tag next to
numeric settings whose current value equals the shipped default.

The tag deliberately compares values, not provenance: a stored 2096
and a fallback 2096 behave identically, so they read identically, and
the tag reacts live as the user types.

The backing endpoint filters defaultValueMap through the AllSetting
field set, so per-install material (secret, panelGuid, node mTLS
keys) and redacted credential fields never leave the server; a test
pins that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): keep the default tag out of the accessible name, pin the defaults contract

From review, in order of severity:

The badge was rendered inside the element whose id feeds the
control's aria-labelledby, so a visible tag changed every field's
accessible name ('Panel Port Default'). The title text now carries
the id on its own span and the badge sits beside it.

The same default values live in three places: the Go defaultValueMap,
the frontend AllSetting class, and the tag's verdict. A new contract
test parses the Go map's string literals and asserts every shared key
matches the AllSetting class default through the tag's own
comparison — and on first run it caught two real drifts
(tgEnabledEvents / smtpEnabledEvents defaulted to '' in the class but
'login.attempt,cpu.high' on the server), now aligned.

matchesFactoryDefault no longer coerces blank or unparsable defaults
(Number('') is 0; a junk string is not false). The Go tests are
table-driven t.Run subtests and gained the structural invariant:
every returned key is an AllSetting json tag outside the credential
deny-list. The service doc comment now describes the projection
mechanism instead of overclaiming; the i18n key is re-indented and
placed at the head of pages.settings in all 13 locales; the fetch
falls back to {} when validation fails; and smtpPort gets the tag so
plain numeric settings-list fields are covered uniformly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:03:21 +02:00
..

3x-ui frontend

React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles — index.html (admin panel SPA, all /panel/* routes), login.html (login + 2FA), and subpage.html (public subscription viewer). All three are built into ../internal/web/dist/ and embedded into the Go binary via embed.FS.

State is split between local useState, TanStack Query for server state, and useTheme / useWebSocket contexts. Form validation, API parsing, and the xray config model all run through a single shared Zod schema tree (see Schemas).

Dev

npm install
npm run dev

Vite serves on http://localhost:5173/. API calls and /panel/* routes proxy to the Go panel at http://localhost:2053/, so start the Go panel first (go run main.go) and then Vite. The proxy auto-rewrites /panel, /panel/settings, /panel/inbounds, /panel/xray to the matching Vite-served HTML, so the sidebar's production-style links work without round-tripping through Go.

Scripts

Command What
npm run dev Vite dev server with API + WS proxy to Go
npm run build Regenerates OpenAPI + Zod, then builds into ../internal/web/dist/
npm run preview Serve the built bundle locally
npm run typecheck tsc --noEmit (strict, no emit)
npm run lint ESLint flat config (@typescript-eslint + react-hooks)
npm run test Vitest single run (schema fixtures, link parsers, …)
npm run test:watch Vitest watch mode
npm run storybook Storybook dev server on :6006 (component workbench + autodocs)
npm run build-storybook Static Storybook build — CI compile-checks every story
npm run gen:api Build public/openapi.json from pages/api-docs/endpoints.ts
npm run gen:zod Run the Go-side openapigen tool → src/generated/{zod,types}.ts

CI runs typecheck, lint, test, build, and build-storybook on every PR (see ../.github/workflows/ci.yml).

One-off: scan for deprecated APIs

Run this command to sweep the codebase for usages of APIs marked with the JSDoc @deprecated tag (AntD prop renames, Zod renames, removed Web APIs, etc.):

npx eslint --config eslint.deprecated.config.js src

It's a type-aware ESLint run against eslint.deprecated.config.js and is not wired into npm run lint because typed linting triples the wall-clock time.

Production build

npm run build

Outputs to ../internal/web/dist/ (HTML at the root, hashed JS/CSS under assets/). manualChunks splits AntD, icons, codemirror, and react-query into separate vendor bundles to keep the per-page initial JS small. The Go binary embeds this directory at compile time and internal/web/controller/dist.go serves the per-page HTML.

Layout

frontend/
├── index.html, login.html, subpage.html  # 3 Vite entries
├── tsconfig.json
├── eslint.config.js
├── eslint.deprecated.config.js           # On-demand type-aware lint config that flags
│                                         #   usages of APIs marked with JSDoc @deprecated
├── vitest.config.ts
├── vite.config.js
├── .storybook/                           # Storybook config (main.ts, preview.tsx)
├── scripts/
│   └── build-openapi.mjs                 # endpoints.ts → openapi.json
└── src/
    ├── entries/         # Per-page bootstrap (createRoot + render)
    ├── main.tsx         # Shared root for the admin SPA (index.html)
    ├── routes.tsx       # react-router routes mounted under /panel/
    ├── pages/           # One folder per route, page component + helpers
    │   ├── index/, login/, inbounds/, clients/, xray/, nodes/,
    │   ├── settings/, api-docs/, sub/
    ├── layouts/         # AdminLayout (sidebar + header + outlet)
    ├── components/      # Cross-page React components (+ co-located *.stories.tsx)
    ├── hooks/           # useClients, useTheme, useWebSocket, …
    ├── api/             # fetch client + CSRF handling, TanStack Query bridge,
    │                    #   WebSocket client + queryClient.ts
    ├── i18n/            # react-i18next init (locales in internal/web/translation/)
    ├── lib/xray/        # Pure functions: link generation, defaults,
    │                    #   form ⇄ wire adapters, protocol capabilities
    ├── schemas/         # Zod source-of-truth (see "Schemas" below)
    ├── generated/       # Code-generated zod + ts types from Go
    │                    #   (DO NOT hand-edit — regenerated by gen:zod)
    ├── models/          # Thin legacy types still in transit
    │                    #   (DBInbound, Status, AllSetting)
    ├── styles/          # Shared CSS modules
    ├── test/            # Vitest specs + golden fixtures
    │   ├── *.test.ts
    │   ├── __snapshots__/
    │   └── golden/fixtures/  # Per-(protocol × network × security) JSON
    └── utils/           # HttpUtil, ClipboardManager, SizeFormatter, …

Schemas

src/schemas/ is the single source of truth for the xray configuration model. Every API response is parsed through it, every form field is validated against it, and TypeScript types are inferred via z.infer<typeof X> — never hand-written.

schemas/
├── primitives/      # Atomic reusable schemas (port, protocol, sniffing, …)
├── api/             # Backend response shapes (e.g. SlimInboundSchema)
├── forms/           # User-facing form shapes (narrower than api/)
├── protocols/
│   ├── inbound/     # Per-protocol settings (vmess, vless, trojan, …)
│   ├── outbound/
│   ├── stream/      # Network transports (tcp, ws, grpc, xhttp, kcp, …)
│   └── security/    # TLS, Reality, none
├── client.ts, dns.ts, routing.ts, setting.ts, status.ts, xray.ts
└── _envelope.ts     # Generic `Msg<T>` envelope wrapper

Patterns:

  • Discriminated unions for polymorphic data — inbound settings is z.discriminatedUnion('protocol', […]), same for stream and security.
  • Three validation layers, non-overlapping:
    • API boundary: parseMsg(msg, schema, ctx) inside TanStack Query queryFn — warn-only in prod, throws in dev
    • Form input: antdRule(schema.shape.field) on every <Form.Item> — blocks submit + per-field inline error
    • Wire request: Schema.parse(payload) inside mutationFn — throws, because a malformed payload here is always a developer bug
  • No .loose() or [key: string]: any in production schemas. @typescript-eslint/no-explicit-any: error is enforced.

Form pattern (Pattern A)

All non-trivial modals use this single pattern:

const [form] = Form.useForm<InboundFormValues>();

const onFinish = async () => {
  const values = await form.validateFields();
  await createInbound.mutateAsync(values);
};

<Form form={form} onFinish={onFinish}>
  <Form.Item
    name="port"
    label="Port"
    rules={[antdRule(InboundFormSchema.shape.port, t)]}
  >
    <InputNumber min={1} max={65535} />
  </Form.Item>
</Form>

No safeParse-on-submit handlers, no useRef<any> for form references, no inline z.string().min(1) in rules. Conditional fields use <Form.Item dependencies={...} shouldUpdate> with the nested protocol schema.

Testing

Vitest runs everything under src/test/. Schemas have golden fixture suites — one JSON per (protocol × network × security) combination round-tripped through schema.parse → link generator → snapshot. Regenerate snapshots after intentional changes:

npx vitest run -u

Fixtures live in src/test/golden/fixtures/ and are auto-discovered via import.meta.glob.

Storybook

Reusable components in src/components/ are developed and documented in Storybook (@storybook/react-vite). It is a component workbench, not part of the shipped panel — nothing here is embedded into the Go binary. The built Storybook is published with the docs site at docs.sanaei.dev/storybook by .github/workflows/docs-deploy.yml.

npm run storybook        # dev server on http://localhost:6006
npm run build-storybook  # static build; CI runs this to compile-check every story

Addons: @storybook/addon-docs renders an autodocs page per component, @storybook/addon-a11y flags accessibility issues in the canvas, and @storybook/addon-vitest runs every story as a headless-browser test under npm run test (Playwright/Chromium — run npx playwright install chromium once locally). The .storybook/preview.tsx decorator wraps every story in the AntD ConfigProvider and adds a light/dark theme toggle to the toolbar.

Conventions for a story:

  • Co-locate it with its component as <Component>.stories.tsx.
  • Set tags: ['autodocs'] so it gets a generated docs page.
  • Document props via story metadata, not JSDoc (the repo bans // comments): a component summary in parameters.docs.description.component and per-prop text in argTypes[prop].description. satisfies Meta<typeof Component> keeps the metadata type-checked.

Adding a new page

Most new routes go inside the admin SPA (index.html) via routes.tsx — no new HTML or Vite entry needed.

  1. Add the page component under src/pages/<page>/.
  2. Register it in src/routes.tsx under the /panel/... tree.
  3. If you need a brand-new top-level bundle (login-style standalone page), add the HTML at frontend/<page>.html, an entry at src/entries/<page>.tsx, and register it in rollupOptions.input in vite.config.js. Then add the Go controller call to serveDistPage(c, "<page>.html").