mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
3a2f9b48da
* feat(web): add network-only PWA installability Serve the manifest, registration script, network-only service worker, and icons under the runtime web base path so panels remain installable at arbitrary configured URLs. This does not add offline caching or change panel, API, database, or Xray behavior. * chore(docs): remove development planning notes Keep the pull request focused on the PWA implementation, tests, and user-facing verification documentation. * feat(web): adopt the 3X logo PWA icon set from #1865 Replace the two placeholder SVG icons with the six-size PNG set (16/24/32/64/192/512) contributed by @Incognito-Coder in PR #1865. The PNGs have transparent rounded corners, so the manifest entries drop the maskable purpose claim and rely on the default any. --------- Co-authored-by: korsun009 <277924786+korsun009@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
244 lines
10 KiB
Markdown
244 lines
10 KiB
Markdown
# 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](#schemas)).
|
||
|
||
## Dev
|
||
|
||
```sh
|
||
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.):
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
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.
|
||
|
||
### PWA mode
|
||
|
||
The login and panel pages expose a minimal network-only Progressive Web App.
|
||
The manifest, service worker, registration script, and icons are embedded with
|
||
the frontend and served under the runtime `webBasePath`. The service worker
|
||
does not use Cache Storage, does not intercept requests, and does not provide
|
||
offline access; panel authentication, API calls, and WebSocket traffic remain
|
||
normal network requests.
|
||
|
||
## 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:
|
||
|
||
```tsx
|
||
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:
|
||
|
||
```sh
|
||
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](https://docs.sanaei.dev/storybook/) by
|
||
`.github/workflows/docs-deploy.yml`.
|
||
|
||
```sh
|
||
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")`.
|