Files
3x-ui/frontend
Egor bf7ce2daaa feat(discord): add Discord notification bot service (#6486)
* feat(discord): add Discord notification bot service, settings UI, and event subscriber
- internal/web/service/discord: implement lightweight Discord REST API v10 client and EventBus subscriber
- internal/web/service/setting: add discordBotEnable, discordBotToken, discordChannelId, discordEnabledEvents, discordCpu, discordMemory settings and secret protection
- internal/web/controller: register POST /panel/api/setting/testDiscord endpoint
- frontend: add Discord settings tab, notifications configuration, sidebar navigation, and command palette integration
- translation: add localization keys across all 13 locales
- tests: add comprehensive unit tests with httptest server and verify route/i18n contracts

* fix(discord): address PR review findings on concurrency, linting, i18n, and stories

- subscriber: eliminate unbounded goroutines, sending inline per EventBus contract
- discord: accept context.Context in SendMessage, SendEmbed, SendTest with http.NewRequestWithContext
- format: apply gofumpt to controller and entity struct alignments
- i18n: localize testDiscord controller responses across all 13 locales
- storybook: add DiscordNotifications.stories.tsx component story

* docs: add Discord bot setup and operations guide

- add docs/content/docs/en/operations/discord-bot.mdx with setup steps, event indicators, settings, and troubleshooting
- add docs/content/docs/ru/operations/discord-bot.mdx with localized instructions
- update operations/meta.json across en, ru, zh, fa
- link Discord bot from panel configuration overview

* feat(discord): add discordLang, discordRunTime, discordBotBackup settings and update settings UI

- internal/web/entity: add DiscordRunTime, DiscordBotBackup, DiscordLang fields to AllSetting
- internal/web/service/setting: add defaultValueMap entries, getters, and setters
- frontend: update AllSetting schema, model defaults, and generate OpenAPI / Zod contracts
- frontend: extract shared NotifyTimeField component and update DiscordTab with General and Notifications tabs
- translation: add localization keys across all 13 locales

* feat(discord): implement scheduled status reports and database backup attachments

- internal/web/service/discord: add SendMessageWithFiles supporting multipart uploads
- internal/web/service/discord: implement BuildReport and SendReport generating rich status embeds
- internal/web/service/discord: attach database backup (and config.json) when discordBotBackup is enabled
- internal/web/job: implement DiscordNotifyJob scheduled via robfig/cron
- internal/web/locale: add LocalizerFor and I18nForLang helpers
- internal/web/controller: trigger reloadDiscordFunc to dynamically reschedule cron upon setting changes
- internal/web/web: register and reschedule DiscordNotifyJob
- tests: comprehensive unit tests for multipart uploads, status reporting, and job execution

* feat(discord): add interactive bot commands via Gateway WebSocket and update documentation

- internal/web/service/discord/gateway: connect to Discord Gateway v10 via WebSocket (gorilla/websocket)
- internal/web/service/discord/gateway: handle heartbeat loop, reconnection, and command dispatch
- commands: implement !status, !report, !backup, !usage <email>, !inbounds, !restart, !help (with ! and / prefixes)
- internal/web/web: start/stop Gateway client with server and reload dynamically on setting updates
- docs: update operations guide (en, ru) with scheduled reports, backups, commands, and privileged intents
- tests: add end-to-end WebSocket Gateway test verifying command handling

* style(discord): fix goimports formatting and add 3x-ui to gitignore

* fix(discord): stop gateway panics, reconnect storms and proxy bypass

The Gateway client wrote to its websocket from both the heartbeat ticker
and the read loop answering server-requested op 1 heartbeats. gorilla
panics on concurrent writes and neither goroutine recovers, so a colliding
heartbeat took the whole panel process down; writes now share writeMu.

It also reconnected every 5s forever after close codes Discord marks
non-reconnectable (4004 bad token, 4010-4014, including 4014 when Message
Content Intent is off), re-identifying and logging a warning each time.
The loop now stops on those codes; the docs say to restart the panel.

The gateway dialed with websocket.DefaultDialer, bypassing the panel
egress proxy the REST client already uses, so where Discord is filtered
notifications arrived but commands never connected.

* fix(discord): deliver the scheduled report when the backup upload fails

SendReport posted the report embed and the x-ui.db/config.json attachments
in one multipart request. Once the database outgrows Discord's upload cap
(20 MiB by default) the request is rejected and the report embed is lost
with it on every run, leaving only a log warning. Send the embed first and
the attachments as a second message.

* chore(discord): delete tests that pass whether or not the code works

TestDiscordNotifyJob_NilServiceNoPanic and TestHandleEvent_NilDiscordService
feed a nil DiscordService that web.go never passes, and
TestDiscordNotifyJob_DisabledNoPanic passes with or without the enable
guard because Xray is not running under test.

* fix(discord): require admin user IDs for bot commands and honor discordLang

Any member who could post in the configured channel could run !backup
(the whole x-ui.db and config.json, even with discordBotBackup off),
!restart and !usage. Commands now run only for the Discord user IDs in
the new discordAdminIds setting; an empty list turns commands off.

discordLang was saved and offered in the UI, but nothing read it, so
every embed stayed English. The test message, alerts, the scheduled
report and command replies now render through I18nForLang in the chosen
language, with a discord section in all 13 locales. InitLocalizer takes
an fs.FS so tests load the real translation files.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 14:04:53 +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 oxlint over src/ + tools/ (.oxlintrc.json)
npm run lint:deprecated Type-aware sweep for JSDoc @deprecated APIs (on demand)
npm run format oxfmt (.oxfmtrc.json) — rewrites src/ + tools/ in place
npm run format:check oxfmt in check mode (no writes)
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, format:check, 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.):

npm run lint:deprecated

It is oxlint's type-aware mode (oxlint-tsgolint, which drives the TypeScript 7 typescript-go checker) narrowed to no-deprecated, and is not wired into npm run lint because typed linting needs a full type-check pass.

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.

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
├── .oxlintrc.json                        # oxlint config (replaces the ESLint flat config)
├── .oxfmtrc.json                         # oxfmt config (Prettier-compatible settings)
├── tools/oxlint/
│   └── input-number-guard.mjs            # oxlint JS plugin: the #6121/#6127 cleared-
│                                         #   InputNumber guard (oxlint has no
│                                         #   no-restricted-syntax)
├── 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/no-explicit-any: error is enforced by oxlint.

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").