* feat(clients): renew on a calendar day instead of a rolling interval Auto-renew advances the expiry by a fixed number of milliseconds, so a client set to 30 days drifts against the calendar: renewing on 31 January lands on 2 March, and by the end of the year the billing day has wandered a fortnight from where the operator's own plan resets. Add a per-client renewal day. When set, the expiry steps whole calendar months at midnight in the panel's time zone. A month too short for the chosen day renews on its last day and the following month returns to the chosen one, so the 31st does not decay into the 28th permanently. Zero keeps the interval mode, so existing clients are untouched. The interval branch now also refuses a zero step. It is unreachable while the selection filter holds, but that loop runs on the single traffic writer, and a zero interval there would hang every panel mutation behind it. * fix(clients): persist the calendar renewal day on the client record resetDay lived only in the inbound settings JSON and client_traffics, so every path that rebuilds a client from the clients table wrote it back as zero: an ordinary edit, an attach to a second inbound, a traffic reset on a disabled client. Calendar mode turned itself off during normal use and the operator only found out a month later. Adds reset_day to ClientRecord and threads it through ToRecord, ToClient, applyClientRecordMerge and the record update map, so the value survives the round trip. The clients page filter and ClientSlim now recognise the mode, nodeClientRenewed classifies a calendar renewal as a renewal, the node snapshot merge carries reset_day, and the service layer rejects a day outside 0-31 rather than clamping it silently. Also renames the label keys to renewOnDay to keep them apart from the existing renewDays, translates them and the new RESET_DAY subscription placeholder in all 13 locales, adds the field to the bulk-add modal, and drops the stray internal/web/dist/.gitkeep build stub. * fix(clients): let the billing day be changed after creation ClientService.Update writes the record columns directly only for a client with no inbounds. The normal path goes through SyncInbound and applyClientRecordMerge, which this change had not extended, so moving a client from the 20th to the 5th updated the inbound settings JSON while clients.reset_day kept the old value and the renewal kept using it. The existing test did not catch it: it asserted the day survived an unrelated edit, and it survived precisely because nothing on that path ever wrote it. TestClientEditChangesTheBillingDay moves the day and then switches calendar mode off again; removing the record write turns it red. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
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
settingsisz.discriminatedUnion('protocol', […]), same for stream and security. - Three validation layers, non-overlapping:
- API boundary:
parseMsg(msg, schema, ctx)inside TanStack QueryqueryFn— 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)insidemutationFn— throws, because a malformed payload here is always a developer bug
- API boundary:
- No
.loose()or[key: string]: anyin production schemas.@typescript-eslint/no-explicit-any: erroris 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 inparameters.docs.description.componentand per-prop text inargTypes[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.
- Add the page component under
src/pages/<page>/. - Register it in
src/routes.tsxunder the/panel/...tree. - If you need a brand-new top-level bundle (login-style standalone
page), add the HTML at
frontend/<page>.html, an entry atsrc/entries/<page>.tsx, and register it inrollupOptions.inputinvite.config.js. Then add the Go controller call toserveDistPage(c, "<page>.html").