Compare commits

..

14 Commits

Author SHA1 Message Date
dependabot[bot] baa1a93057 chore(deps): bump react-i18next from 17.0.9 to 17.0.10 in /frontend
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.9 to 17.0.10.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.9...v17.0.10)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 09:08:54 +00:00
Tomi lla 129f50d92a feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)
* feat(settings): add subscription format controls

* feat(sub): auto-detect subscription formats

* fix(xray): validate balancer regexes before save

* Revert "fix(xray): validate balancer regexes before save"

This reverts commit 8a208ce71b.

* doc(endpoints): align indent spaces

* doc(settings): improve error message formatting in validateSubUserAgentRegex

- Use NewErrorf with proper formatting instead of NewError with string concatenation
- Add comment explaining the rationale for returning original pattern value
- This preserves the intentional design where empty input is stored as empty
  in the DB and inherited as the runtime default at read time

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-07-14 13:01:40 +02:00
H-TTTTT f2b17397f4 fix(frontend): stabilize speed tags on inbound and client pages (#5930)
* fix(frontend): add shared stable speed-tag style

Give live up/down rate tags a fixed width, centered layout, nowrap,
and tabular numerals so digit/unit changes cannot reflow the Speed column.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): stabilize InboundSpeedTag and ClientSpeedTag layout

Apply the shared speed-tag class/style to both live rate tags and lock
the behavior with a focused component test for small and large rates.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): align speed columns with stable tag width

Widen inbound/client Speed columns to match the fixed tag and apply the
same stable style to idle dash cells so active/idle swaps do not jitter.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): scope stable speed tags to table cells and fit content

---------

Co-authored-by: x06579 <x06579@ai-dashboard>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-14 13:00:25 +02:00
Sangeeth Thilakarathna 658e6ab3d3 feat(frontend): show client comments on mobile cards (#5942)
* feat(frontend): show client comments on mobile cards

* fix(frontend): bound mobile comment height

---------

Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com>
2026-07-14 12:59:55 +02:00
Yuri Khachaturyan 1cfd7b49b0 fix(email): build an RFC 5322 message with a proper From address and name (#5941)
The notification/test email carried only From/To/Subject/MIME headers, and
the From header was the raw SMTP username. Two problems:

- When the SMTP login is not a bare email address (common with relays and
  submission services), the From header has no valid address and strict
  receivers reject the message — e.g. Gmail returns "550-5.7.1 ... Messages
  missing a valid address in From: header".
- There was no Date (mandatory per RFC 5322 section 3.6) and no Message-ID,
  which also raises spam score.

Add smtpFrom (sender address) and smtpFromName (display name) settings and
assemble the message with net/mail: a name-addr From ("Name" <addr>), a
Date, a Message-ID, and an RFC 2047 encoded Subject, in a deterministic
header order. From falls back to the username when smtpFrom is empty, so
existing setups keep working. Wire the settings through the model, the SMTP
send and test paths, the Email settings UI, and all 13 locale files;
regenerate the Zod/OpenAPI artifacts.

Validate smtpFrom in AllSetting.CheckValid (reject anything net/mail cannot
parse), which surfaces a bad address at configuration time and prevents CRLF
header injection; strip CR/LF in buildMessage as defense in depth. Add
buildMessage and CheckValid tests.
2026-07-14 12:55:46 +02:00
Matt Van Horn ae0da4c51f fix: stop forcing port 53 on DoH/DoQ DNS server entries (#5950)
Object-form DNS server entries always received port: 53, because
DnsServerObjectInnerSchema defaulted the port unconditionally and the
DnsServerModal wire adapter always wrote it. Per Xray-core, encrypted
schemes must not carry a port field; a non-standard port is embedded in
the URL instead.

Default the port to 53 only for non-encrypted addresses and omit it for
the encrypted DNS schemes Xray dispatches without a port - https,
https+local, h2c, h2c+local and quic+local - both in the Zod schema and
in the modal's valuesToWire adapter. Schemes are matched
case-insensitively to mirror Xray-core's EqualFold comparison. A shared
isEncryptedDnsAddress helper backs both paths.

Fixes #5920

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-14 12:55:10 +02:00
Sangeeth Thilakarathna 65b5074b60 fix(script): remove release download time limit (#5952)
* fix(script): remove release download time limit

* fix(script): stop stalled release downloads

---------

Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com>
2026-07-14 12:44:55 +02:00
Mikhail Grigorev b18c87dc4b fix(script): Remove old mtg binary (#5955)
Co-authored-by: Mikhail Grigorev <grigorev_mm@magnit.ru>
2026-07-14 12:44:22 +02:00
MHSanaei b11ceac18e fix(ci): install the docs-pinned pnpm instead of floating on 11.x
pnpm/action-setup resolved 'version: 11' to the newest 11.x, and its self-installer crashes upgrading to 11.12.0 (Cannot use 'in' operator to search for 'integrity'), failing both docs workflows at setup. Reading docs/package.json instead installs the exact packageManager pin (pnpm@11.9.0), which also keeps the workflows and the lockfile toolchain on a single source of truth.
2026-07-14 03:50:16 +02:00
MHSanaei bbc4163768 chore: standardize the toolchain on Node 24 LTS
The repo now pins Node 24 everywhere instead of mixing 22 and hardcoded workflow versions. The docs workflows read .nvmrc like the main CI already did, so the Storybook bundle in the Pages deploy builds on the same runtime as the PR gate. The docs gen:api script runs its TypeScript entry natively, dropping the experimental type-stripping flag that Node 24 makes default; the matching frontend cleanup (engines and gen:api) landed with the Storybook commit.
2026-07-14 03:39:03 +02:00
MHSanaei ee9a6067c2 refactor(frontend): migrate off deprecated Ant Design 6 props
The repo's type-aware deprecation sweep (eslint.deprecated.config.js) reported fourteen findings; it now reports zero. Alert message becomes title and closable+onClose becomes closable.onClose; Select optionFilterProp moves into showSearch.optionFilterProp and suffixIcon becomes suffix; Drawer width becomes size; Progress trailColor becomes railColor. Behavior is unchanged apart from a few single-mode selects gaining type-to-filter, which the old prop already implied.
2026-07-14 03:38:41 +02:00
MHSanaei 60316c831f fix(frontend): resolve every axe accessibility violation in the component library
Running the stories under axe surfaced real panel defects, not just story cosmetics. FormField never associated its Form.Item label with the wrapped control, so no RHF form field in the panel had a programmatic label; it now generates an id and wires htmlFor. Unnamed controls get accessible names: the prompt and text modal inputs (from the modal title), the client traffic progress bar (used/limit values), the CPU and RAM threshold inputs in the notification groups (event label threaded through the extra renderer), and the JSON editor's contenteditable surface.

ConfigBlock's collapse header carried role=button around focusable action buttons; collapsible=header scopes the toggle to the label. Light theme gains contrast-safe tokens shared by the panel and Storybook: darker description, placeholder, error and success text, a darker primary button blue, and a readable gold tag, all meeting the WCAG AA 4.5:1 ratio. The infinity badge swaps a prohibited bare aria-label for role=img.
2026-07-14 03:38:14 +02:00
MHSanaei df3ba568d1 feat(docs): publish the component Storybook on the docs site
The docs site and the component workbench were entirely disconnected. The Pages deploy now builds the frontend Storybook and bundles it into the artifact under /storybook, so the live component reference ships with the documentation, and the navbar links to it. Story changes trigger a redeploy so the published workbench cannot go stale.
2026-07-14 03:37:55 +02:00
MHSanaei 7078abc14a feat(frontend): make Storybook a validated, fully covered component workbench
Storybook existed only as an undocumented local tool: 9 of 24 reusable components had stories, autodocs pages were bare prop tables, nothing built or tested the stories, and no contributor doc mentioned the workbench existed.

Every reusable component under src/components/ now has a co-located story with enriched autodocs (component descriptions plus per-prop argTypes, kept as string metadata since the repo bans line comments). Stories double as headless Chromium tests through the Storybook vitest addon, with axe accessibility checks enforced as errors and play-function interaction tests covering the modals, the RHF field bridge, the config block, and the select-all buttons. The preview now mirrors the panel's real theme DOM (body class, shared AntD theme config, seeded theme storage) so what stories render matches production.

CI and make verify gain a static Storybook build as a compile gate, and the frontend test job installs Chromium so story tests run on every PR. Contributor docs (frontend README, CONTRIBUTING, agent guides) document the workbench, the story conventions, and the Controls setup. Node engines move to 24 LTS and gen:api drops the type-stripping flags that Node 24 makes default.
2026-07-14 03:37:21 +02:00
124 changed files with 4530 additions and 392 deletions
+6
View File
@@ -168,12 +168,18 @@ jobs:
- name: Typecheck
run: npm run typecheck
working-directory: frontend
- name: Install Playwright Chromium (Storybook story tests)
run: npx playwright install --with-deps chromium
working-directory: frontend
- name: Test
run: npm test
working-directory: frontend
- name: Build
run: npm run build
working-directory: frontend
- name: Build Storybook
run: npm run build-storybook
working-directory: frontend
- name: Audit
run: npm audit --audit-level=high
working-directory: frontend
+2 -2
View File
@@ -26,11 +26,11 @@ jobs:
- uses: pnpm/action-setup@v6
with:
version: 11
package_json_file: docs/package.json
- uses: actions/setup-node@v6
with:
node-version: 22
node-version-file: .nvmrc
cache: pnpm
cache-dependency-path: docs/pnpm-lock.yaml
+12 -2
View File
@@ -9,6 +9,9 @@ on:
branches: [main]
paths:
- 'docs/**'
- 'frontend/src/components/**'
- 'frontend/.storybook/**'
- 'frontend/package-lock.json'
- '.github/workflows/docs-deploy.yml'
workflow_dispatch:
@@ -32,10 +35,10 @@ jobs:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 11
package_json_file: docs/package.json
- uses: actions/setup-node@v6
with:
node-version: 22
node-version-file: .nvmrc
cache: pnpm
cache-dependency-path: docs/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
@@ -52,6 +55,13 @@ jobs:
# in place. That way both /docs/... and /en/docs/... resolve. Other
# locales stay under /fa, /ru, /zh.
run: cp -a out/en/. out/
- name: Build the component Storybook (frontend/)
working-directory: frontend
run: |
npm ci
npm run build-storybook
- name: Bundle Storybook at /storybook
run: cp -a ../frontend/storybook-static out/storybook
- uses: actions/upload-pages-artifact@v5
with:
path: docs/out
+1 -1
View File
@@ -1 +1 @@
22
24
+6 -4
View File
@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
## Prerequisites
- **Go 1.26+** (the version pinned in `go.mod`)
- **Node.js 22+** and npm 10+ (for the React frontend)
- **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
- **Git**
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
@@ -157,12 +157,13 @@ Panel navigation happens client-side through React Router, and per-route code is
Locale strings live in `internal/web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
### Two dev workflows
### Dev workflows
| Goal | Command |
|------|---------|
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
| Develop/preview a reusable component in isolation | `cd frontend && npm run storybook` (Storybook workbench + autodocs on `:6006`). |
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
@@ -189,6 +190,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
- **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.
- **Reusable components are documented in Storybook.** When you add or change a component in `frontend/src/components/`, add or update its co-located `<Component>.stories.tsx` (`tags: ['autodocs']`), documenting props via `argTypes` / `parameters.docs` string metadata rather than JSDoc. CI compile-checks every story via `npm run build-storybook` and runs each story as a headless-browser test via `@storybook/addon-vitest` (`npm run test`, needs `npx playwright install chromium`); run `npm run storybook` to preview locally.
### Project layout
@@ -277,7 +279,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`. 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`/`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
@@ -286,7 +288,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` (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 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` — `<area>: 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.
+6 -2
View File
@@ -68,8 +68,12 @@ build-fe: ## Build the Vite bundles into internal/web/dist
build: build-fe ## Build the frontend then the Go binary
go build ./...
.PHONY: build-storybook
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, and a full build.
# both test suites, a full build, and the Storybook compile-check.
.PHONY: verify
verify: gen-check lint typecheck test build ## Full local gate (mirrors CI)
verify: gen-check lint typecheck test build build-storybook ## Full local gate (mirrors CI)
@echo "verify: OK"
+7 -1
View File
@@ -2,7 +2,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { Heart } from 'lucide-react';
import { Logo } from '@/components/logo';
import { TelegramIcon } from '@/components/icons';
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl } from './shared';
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
import { getSiteMessages } from './site-i18n';
// Build locale-aware shared layout options. With `hideLocale: 'default-locale'`,
@@ -28,6 +28,12 @@ export function baseOptions(lang: string): BaseLayoutProps {
url: `${prefix}/docs`,
active: 'nested-url',
},
// Live component workbench built from frontend/ and published alongside the docs.
{
text: 'Storybook',
url: `${siteUrl}/storybook/`,
external: true,
},
{
type: 'icon',
label: `Telegram channel (@${telegramChannel})`,
+1 -1
View File
@@ -9,7 +9,7 @@
"build": "next build",
"start": "next start",
"postinstall": "fumadocs-mdx",
"gen:api": "node --experimental-strip-types scripts/gen-openapi.ts",
"gen:api": "node scripts/gen-openapi.ts",
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
+5 -1
View File
@@ -6,7 +6,11 @@ const config: StorybookConfig = {
options: {},
},
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
addons: [
'@storybook/addon-docs',
'@storybook/addon-a11y',
'@storybook/addon-vitest'
],
viteFinal: (viteConfig) => {
if (viteConfig.build) {
viteConfig.build.outDir = undefined;
+6
View File
@@ -0,0 +1,6 @@
<script>
if (localStorage.getItem('dark-mode') === null) localStorage.setItem('dark-mode', 'false');
if (localStorage.getItem('isUltraDarkThemeEnabled') === null) {
localStorage.setItem('isUltraDarkThemeEnabled', 'false');
}
</script>
+9 -2
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react';
import type { Decorator, Preview } from '@storybook/react-vite';
import { ConfigProvider, theme as antdTheme } from 'antd';
import { ConfigProvider } from 'antd';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { buildAntdThemeConfig } from '@/hooks/useTheme';
import enUS from '../../internal/web/translation/en-US.json';
if (!i18next.isInitialized) {
@@ -19,10 +20,11 @@ if (!i18next.isInitialized) {
const withTheme: Decorator = (Story, context) => {
const dark = context.globals.theme === 'dark';
useEffect(() => {
document.body.setAttribute('class', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}, [dark]);
return (
<ConfigProvider theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm }}>
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
<div style={{ padding: 24, minWidth: 320 }}>
<Story />
</div>
@@ -49,11 +51,16 @@ const preview: Preview = {
},
parameters: {
controls: {
expanded: true,
sort: 'requiredFirst',
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
test: 'error',
},
},
};
+4
View File
@@ -63,5 +63,9 @@ Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
- `npm run typecheck` / `npm run lint` / `npm run test` / `npm run build`.
- `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
(`build-openapi.mjs``public/openapi.json`).
- `npm run storybook` (workbench on :6006) / `npm run build-storybook` (CI
compile-checks every story). Reusable `src/components/` get a co-located
`<Component>.stories.tsx` with `tags: ['autodocs']`; document props via
`argTypes` / `parameters.docs` string metadata, never JSDoc.
- After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root
CLAUDE.md) before checking the panel.
+36 -3
View File
@@ -36,11 +36,13 @@ production-style links work without round-tripping through Go.
| `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`, and `build` on every PR
(see `../.github/workflows/ci.yml`).
CI runs `typecheck`, `lint`, `test`, `build`, and `build-storybook` on
every PR (see `../.github/workflows/ci.yml`).
### One-off: scan for deprecated APIs
@@ -79,6 +81,7 @@ frontend/
│ # 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/
@@ -89,7 +92,7 @@ frontend/
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
│ ├── settings/, api-docs/, sub/
├── layouts/ # AdminLayout (sidebar + header + outlet)
├── components/ # Cross-page React components
├── 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
@@ -187,6 +190,36 @@ 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
+396 -201
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -5,7 +5,7 @@
"type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": {
"node": ">=22.0.0",
"node": ">=24.0.0",
"npm": ">=10.0.0"
},
"scripts": {
@@ -19,7 +19,7 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
"gen:api": "node scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true"
},
@@ -34,7 +34,7 @@
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"antd": "^6.5.0",
"antd": "^6.5.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.6",
@@ -43,7 +43,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-i18next": "^17.0.9",
"react-i18next": "^17.0.10",
"react-router-dom": "^7.18.1",
"swagger-ui-react": "^5.32.8",
"uplot": "^1.6.32",
@@ -53,6 +53,7 @@
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.0",
"@storybook/addon-docs": "^10.5.0",
"@storybook/addon-vitest": "^10.5.0",
"@storybook/react-vite": "^10.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
@@ -71,9 +72,11 @@
"msw": "^2.15.0",
"storybook": "^10.5.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
"typescript-eslint": "^8.64.0",
"vite": "8.1.4",
"vitest": "^4.1.10"
"vitest": "^4.1.10",
"@vitest/browser-playwright": "4.1.10",
"playwright": "^1.61.1"
},
"overrides": {
"eslint-plugin-jsx-a11y": {
+103
View File
@@ -142,6 +142,12 @@
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
@@ -170,6 +176,9 @@
"subCertFile": {
"type": "string"
},
"subClashAutoDetect": {
"type": "boolean"
},
"subClashEnable": {
"type": "boolean"
},
@@ -185,6 +194,9 @@
"subClashURI": {
"type": "string"
},
"subClashUserAgentRegex": {
"type": "string"
},
"subDomain": {
"type": "string"
},
@@ -206,6 +218,12 @@
"subIncyRoutingRules": {
"type": "string"
},
"subJsonAlwaysArray": {
"type": "boolean"
},
"subJsonAutoDetect": {
"type": "boolean"
},
"subJsonEnable": {
"type": "boolean"
},
@@ -224,6 +242,9 @@
"subJsonURI": {
"type": "string"
},
"subJsonUserAgentRegex": {
"type": "string"
},
"subKeyFile": {
"type": "string"
},
@@ -375,6 +396,8 @@
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
@@ -383,11 +406,13 @@
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
"subDomain",
"subEnable",
"subEnableRouting",
@@ -395,12 +420,15 @@
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
"subJsonPath",
"subJsonRules",
"subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile",
"subListen",
"subPath",
@@ -575,6 +603,12 @@
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
@@ -603,6 +637,9 @@
"subCertFile": {
"type": "string"
},
"subClashAutoDetect": {
"type": "boolean"
},
"subClashEnable": {
"type": "boolean"
},
@@ -618,6 +655,9 @@
"subClashURI": {
"type": "string"
},
"subClashUserAgentRegex": {
"type": "string"
},
"subDomain": {
"type": "string"
},
@@ -639,6 +679,12 @@
"subIncyRoutingRules": {
"type": "string"
},
"subJsonAlwaysArray": {
"type": "boolean"
},
"subJsonAutoDetect": {
"type": "boolean"
},
"subJsonEnable": {
"type": "boolean"
},
@@ -657,6 +703,9 @@
"subJsonURI": {
"type": "string"
},
"subJsonUserAgentRegex": {
"type": "string"
},
"subKeyFile": {
"type": "string"
},
@@ -815,6 +864,8 @@
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
@@ -823,11 +874,13 @@
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
"subDomain",
"subEnable",
"subEnableRouting",
@@ -835,12 +888,15 @@
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
"subJsonPath",
"subJsonRules",
"subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile",
"subListen",
"subPath",
@@ -9247,6 +9303,53 @@
}
}
},
"/panel/api/setting/validateRegex": {
"post": {
"tags": [
"Settings"
],
"summary": "Validate any regular expression with the backend Go RE2 compiler without saving it.",
"operationId": "post_panel_api_setting_validateRegex",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"regex": "(?m)^general-purpose$"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"msg": ""
}
}
}
}
}
}
},
"/panel/api/setting/updateUser": {
"post": {
"tags": [
@@ -0,0 +1,14 @@
type ClientCardCommentProps = {
comment?: string;
className?: string;
};
export default function ClientCardComment({ comment, className = 'client-card-comment' }: ClientCardCommentProps) {
if (!comment) return null;
return (
<span className={className} title={comment}>
{comment}
</span>
);
}
@@ -6,6 +6,17 @@ const meta = {
title: 'Clients/ClientSpeedTag',
component: ClientSpeedTag,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Blue tag showing a live upload/download speed for one client, formatted for readability. Shown next to online clients that have active traffic.',
},
},
},
argTypes: {
speed: { description: 'Live upload/download rate in bytes per second (`{ up, down }`).' },
},
} satisfies Meta<typeof ClientSpeedTag>;
export default meta;
@@ -2,6 +2,7 @@ import { Tag } from 'antd';
import { SizeFormatter } from '@/utils';
import type { ClientSpeedEntry } from '@/hooks/useClients';
import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
export type { ClientSpeedEntry };
@@ -11,11 +12,16 @@ export function isActiveSpeed(speed?: ClientSpeedEntry): speed is ClientSpeedEnt
interface ClientSpeedTagProps {
speed: ClientSpeedEntry;
tableCell?: boolean;
}
export function ClientSpeedTag({ speed }: ClientSpeedTagProps) {
export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps) {
return (
<Tag color="blue">
<Tag
color="blue"
className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
style={tableCell ? SPEED_TAG_STYLE : undefined}
>
{SizeFormatter.speedFormat(speed.up)}
{' / '}
{SizeFormatter.speedFormat(speed.down)}
@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ThemeProvider } from '@/hooks/useTheme';
import ClientTrafficCell from './ClientTrafficCell';
const GiB = 1024 ** 3;
const meta = {
title: 'Clients/ClientTrafficCell',
component: ClientTrafficCell,
tags: ['autodocs'],
decorators: [
(Story) => (
<ThemeProvider>
<Story />
</ThemeProvider>
),
],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Traffic usage cell for the clients table: used bytes, a color-coded progress bar, and the quota (or an infinity icon for unlimited clients), with an upload/download/remaining breakdown in a hover popover.',
},
},
},
argTypes: {
up: { description: 'Uploaded bytes counted against the client.' },
down: { description: 'Downloaded bytes counted against the client.' },
total: { description: 'Traffic quota in bytes; 0 or less renders as unlimited.' },
enabled: { description: 'Grays the bar out when the client is disabled.' },
trafficDiff: { description: 'Headroom in bytes below the quota at which the bar shifts from green to orange.' },
compact: { description: 'Smaller bar and tighter layout for dense table rows.' },
},
} satisfies Meta<typeof ClientTrafficCell>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
up: 6 * GiB,
down: 35 * GiB,
total: 100 * GiB,
trafficDiff: 5 * GiB,
},
};
export const Unlimited: Story = {
args: {
up: 87 * GiB,
down: 940 * GiB,
total: 0,
},
};
export const Depleted: Story = {
args: {
up: 9 * GiB,
down: 42 * GiB,
total: 50 * GiB,
trafficDiff: 5 * GiB,
},
};
export const DisabledCompact: Story = {
args: {
up: 2 * GiB,
down: 11 * GiB,
total: 40 * GiB,
enabled: false,
compact: true,
},
};
@@ -64,6 +64,7 @@ export default function ClientTrafficCell({
<span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
<Progress
className="client-traffic-cell-bar"
aria-label={`${SizeFormatter.sizeFormat(display.used)} / ${display.isUnlimited ? t('subscription.unlimited') : SizeFormatter.sizeFormat(total)}`}
percent={display.percent}
showInfo={false}
strokeColor={display.strokeColor}
@@ -72,7 +73,7 @@ export default function ClientTrafficCell({
/>
<span className="client-traffic-cell-limit">
{display.isUnlimited ? (
<span className="client-traffic-cell-infinity" aria-label={t('subscription.unlimited')}>
<span className="client-traffic-cell-infinity" role="img" aria-label={t('subscription.unlimited')}>
<InfinityIcon />
</span>
) : (
@@ -12,6 +12,10 @@
min-width: 0;
}
body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
color: #874d00;
}
.config-block .ant-collapse-extra {
display: flex;
align-items: center;
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import ConfigBlock from './ConfigBlock';
@@ -6,7 +7,24 @@ const meta = {
title: 'Clients/ConfigBlock',
component: ConfigBlock,
tags: ['autodocs'],
parameters: { layout: 'padded' },
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Collapsible panel that displays a client config or share link with copy, download, and QR-code actions. Used on the clients and inbounds pages to present generated links.',
},
},
},
argTypes: {
label: { description: 'Protocol/type badge shown on the panel header (e.g. `vless`, `trojan`).' },
text: { description: 'The config or share-link text to display, copy, download, and encode as a QR code.' },
fileName: { description: 'File name used when downloading the text.' },
qrRemark: { description: 'Optional remark embedded in the QR panel; falls back to `label`.' },
showQr: { description: 'Whether to show the QR-code action button.' },
tagColor: { description: 'Ant Design tag color for the header badge.' },
defaultOpen: { description: 'Whether the panel starts expanded.' },
},
} satisfies Meta<typeof ConfigBlock>;
export default meta;
@@ -18,6 +36,15 @@ const sampleLink = 'vless://11112222-3333-4444-5555-666677778888@panel.example.c
export const Collapsed: Story = {
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
play: async ({ canvas, userEvent }) => {
await expect(canvas.queryByText(/vless:\/\/11112222/)).not.toBeInTheDocument();
await userEvent.click(canvas.getByText('vless'));
const configText = await canvas.findByText(/vless:\/\/11112222/);
await waitFor(() => expect(configText).toBeVisible());
await expect(canvas.getByRole('button', { name: 'Copy' })).toBeVisible();
await expect(canvas.getByRole('button', { name: 'Download' })).toBeVisible();
await expect(canvas.getByRole('button', { name: 'QR Code' })).toBeVisible();
},
};
export const Expanded: Story = {
@@ -68,6 +68,7 @@ export default function ConfigBlock({
{messageContextHolder}
<Collapse
className="config-block"
collapsible="header"
defaultActiveKey={defaultOpen ? ['cfg'] : []}
items={[{
key: 'cfg',
@@ -1,5 +1,6 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import { Button } from 'antd';
import PromptModal from './PromptModal';
@@ -8,6 +9,25 @@ const meta = {
title: 'Feedback/PromptModal',
component: PromptModal,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Modal that prompts for a single value — a plain input, a multi-line textarea, or a JSON editor. Confirms on Enter (input) or Ctrl+S (textarea) and returns the entered text.',
},
},
},
argTypes: {
open: { description: 'Whether the modal is visible.' },
title: { description: 'Modal title text.' },
okText: { description: 'Confirm button label; defaults to the translated "confirm".' },
type: { description: 'Editor variant: single-line `input` or multi-line `textarea`.' },
initialValue: { description: 'Value pre-filled when the modal opens.' },
loading: { description: 'Shows a loading state on the confirm button.' },
json: { description: 'Render a JSON editor instead of a plain field.' },
onConfirm: { description: 'Called with the entered value on confirm.' },
onClose: { description: 'Called when the modal is dismissed.' },
},
} satisfies Meta<typeof PromptModal>;
export default meta;
@@ -62,9 +82,25 @@ const placeholderArgs = {
export const Input: Story = {
args: placeholderArgs,
render: () => <InputDemo />,
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole('button', { name: 'Rename client' }));
const input = await body.findByRole('textbox');
await userEvent.type(input, 'new-name');
await userEvent.keyboard('{Enter}');
await expect(await canvas.findByText(/Last confirmed: new-name/)).toBeVisible();
},
};
export const Textarea: Story = {
args: placeholderArgs,
render: () => <TextareaDemo />,
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole('button', { name: 'Edit note' }));
const textarea = await body.findByRole('textbox');
await expect(textarea).toHaveValue('line one\nline two');
await userEvent.click(body.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(body.queryByRole('dialog')).not.toBeInTheDocument());
},
};
@@ -72,6 +72,7 @@ export default function PromptModal({
) : type === 'textarea' ? (
<Input.TextArea
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)}
autoSize={{ minRows: 10, maxRows: 20 }}
@@ -80,6 +81,7 @@ export default function PromptModal({
) : (
<Input
ref={inputRef}
aria-label={title}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeydown}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import { Button } from 'antd';
import TextModal from './TextModal';
@@ -8,6 +9,23 @@ const meta = {
title: 'Feedback/TextModal',
component: TextModal,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Read-only modal for viewing generated text or JSON — a client config, subscription, or exported settings — with copy and optional download actions, plus optional tabs for multiple documents.',
},
},
},
argTypes: {
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.' },
tabs: { description: 'Optional list of `{ key, label, content }` documents shown as tabs.' },
onClose: { description: 'Called when the modal is dismissed.' },
},
} satisfies Meta<typeof TextModal>;
export default meta;
@@ -43,6 +61,13 @@ const placeholderArgs = {
export const PlainText: Story = {
args: placeholderArgs,
render: () => <Demo fileName="client.txt" />,
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole('button', { name: 'Show config' }));
const content = await body.findByDisplayValue(/vless:\/\/uuid@example\.com/);
await waitFor(() => expect(content).toBeVisible());
await expect(body.getByRole('button', { name: /client\.txt/ })).toBeEnabled();
},
};
export const Json: Story = {
@@ -75,6 +75,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
<JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
) : (
<Input.TextArea
aria-label={title}
value={activeContent}
readOnly
autoSize={{ minRows: 10, maxRows: 20 }}
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Typography } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { setDatepicker } from '@/hooks/useDatepicker';
import { ThemeProvider } from '@/hooks/useTheme';
import DateTimePicker from './DateTimePicker';
setDatepicker('gregorian');
function ClientExpiryDemo() {
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />
<Typography.Text type="secondary">
{value ? `user1@node-de expiryTime: ${value.valueOf()}` : 'user1@node-de expiryTime: 0 (never expires)'}
</Typography.Text>
</div>
);
}
function JalaliDemo() {
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
useEffect(() => {
setDatepicker('jalalian');
return () => setDatepicker('gregorian');
}, []);
return <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />;
}
const meta = {
title: 'Form/DateTimePicker',
component: DateTimePicker,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Calendar-aware date/time picker used for client and inbound expiry dates. Renders an AntD DatePicker by default and switches to a Persian (Jalali) calendar — with theme-matched colors and an overlaid clear button — when the panel datepicker setting is jalalian.',
},
},
},
decorators: [
(Story) => (
<ThemeProvider>
<Story />
</ThemeProvider>
),
],
argTypes: {
value: { description: 'Selected moment as a Dayjs instance, or null when unset.' },
onChange: { description: 'Called with the picked Dayjs value, or null when cleared.' },
showTime: { description: 'Include an hour/minute/second selector alongside the date.' },
format: { description: 'Display format for the Gregorian picker input.' },
placeholder: { description: 'Input placeholder shown while no value is set.' },
disabled: { description: 'Disables the input and hides the clear button.' },
},
} satisfies Meta<typeof DateTimePicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {
args: {
value: null,
onChange: () => undefined,
placeholder: 'Leave blank to never expire',
},
};
export const ClientExpiry: Story = {
args: { value: null, onChange: () => undefined },
render: () => <ClientExpiryDemo />,
};
export const DateOnly: Story = {
args: {
value: dayjs('2026-08-01'),
onChange: () => undefined,
showTime: false,
format: 'YYYY-MM-DD',
placeholder: 'Start date',
},
};
export const JalaliCalendar: Story = {
args: { value: null, onChange: () => undefined },
parameters: { docs: { disable: true } },
render: () => <JalaliDemo />,
};
@@ -0,0 +1,63 @@
import { useRef, useState } from 'react';
import { Input, Typography } from 'antd';
import { HttpUtil } from '@/utils';
interface GoRegexInputProps {
value: string;
ariaLabel?: string;
placeholder?: string;
maxLength?: number;
onChange: (value: string) => void;
externalError?: string;
}
export async function validateGoRegex(value: string): Promise<string> {
const result = await HttpUtil.post(
'/panel/api/setting/validateRegex',
{ regex: value },
{ silent: true },
);
return result.success ? '' : result.msg || 'Invalid Go RE2 regular expression';
}
export default function GoRegexInput({
value,
ariaLabel,
placeholder,
maxLength = 2048,
onChange,
externalError,
}: GoRegexInputProps) {
const [error, setError] = useState('');
const validationSequence = useRef(0);
async function validate() {
const sequence = ++validationSequence.current;
const nextError = await validateGoRegex(value);
if (sequence === validationSequence.current) {
setError(nextError);
}
}
const displayError = externalError ?? error;
return (
<div style={{ width: '100%' }}>
<Input
value={value}
aria-label={ariaLabel}
placeholder={placeholder}
maxLength={maxLength}
status={displayError ? 'error' : undefined}
onChange={(event) => {
validationSequence.current += 1;
setError('');
onChange(event.target.value);
}}
onBlur={() => void validate()}
/>
{displayError && <Typography.Text type="danger">{displayError}</Typography.Text>}
</div>
);
}
@@ -0,0 +1,76 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import HeaderMapEditor, { type HeaderMapValue } from './HeaderMapEditor';
const meta = {
title: 'Form/HeaderMapEditor',
component: HeaderMapEditor,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Row-based editor for Xray HTTP header maps, used in the inbound/outbound stream forms. Mode `v1` emits one string per header name (WS / HTTPUpgrade / Hysteria masquerade); mode `v2` emits string arrays so headers can repeat (TCP HTTP camouflage request/response).',
},
},
},
argTypes: {
mode: { description: 'Wire shape: `v1` = string per name, `v2` = string[] per name (repeatable headers).' },
value: { description: 'Header map in the wire shape matching `mode`; converted to editable rows internally.' },
onChange: { description: 'Called with the rebuilt wire-shape map after every row edit, add, or remove.' },
},
} satisfies Meta<typeof HeaderMapEditor>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {
args: { mode: 'v1', onChange: () => undefined },
};
export const WsHostHeaders: Story = {
args: {
mode: 'v1',
value: {
Host: 'cdn.example.com',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
onChange: () => undefined,
},
};
export const TcpCamouflageRequest: Story = {
args: {
mode: 'v2',
value: {
Accept: ['text/html,application/xhtml+xml', 'application/json'],
'Accept-Encoding': ['gzip, deflate'],
Connection: ['keep-alive'],
Pragma: ['no-cache'],
},
onChange: () => undefined,
},
};
function WireShapeDemo() {
const [value, setValue] = useState<HeaderMapValue>({
Accept: ['text/html', 'application/json'],
'X-Forwarded-For': ['203.0.113.7'],
});
return (
<div style={{ maxWidth: 560 }}>
<HeaderMapEditor mode="v2" value={value} onChange={setValue} />
<pre style={{ marginTop: 16, padding: 12, borderRadius: 8, background: 'rgba(128, 128, 128, 0.12)' }}>
{JSON.stringify(value ?? {}, null, 2)}
</pre>
</div>
);
}
export const LiveWireShape: Story = {
args: { mode: 'v2', onChange: () => undefined },
render: () => <WireShapeDemo />,
};
@@ -0,0 +1,132 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Typography } from 'antd';
import { ThemeProvider } from '@/hooks/useTheme';
import JsonEditor from './JsonEditor';
const warpOutbound = JSON.stringify(
{
tag: 'warp-out',
protocol: 'wireguard',
settings: {
secretKey: 'yFXfmXX3Zn5tnpNJ7HAcbLvqcMVioqPDGV1GXn2FeV0=',
address: ['172.16.0.2/32', '2606:4700:110:8f81::2/128'],
peers: [
{
publicKey: 'bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: 'engage.cloudflareclient.com:2408',
},
],
mtu: 1280,
},
},
null,
2,
);
const realityStreamSettings = JSON.stringify(
{
network: 'tcp',
security: 'reality',
realitySettings: {
show: false,
dest: 'yahoo.com:443',
xver: 0,
serverNames: ['yahoo.com', 'www.yahoo.com'],
privateKey: 'wLc4dpQvRt8mK1nS9jH2fXaU7yEoB3iZ6vNqTgCkW5A',
shortIds: ['6ba85179e30d4fc2'],
},
},
null,
2,
);
const brokenInboundSettings = [
'{',
' "clients": [',
' {',
' "id": "9f4c3a2b-7d61-4e8a-b5c0-1f2e3d4a5b6c",',
' "email": "user1@node-de",',
' "flow": "xtls-rprx-vision",',
' }',
' ],',
' "decryption": "none"',
].join('\n');
function ControlledDemo() {
const [value, setValue] = useState(warpOutbound);
let parseError = '';
try {
JSON.parse(value);
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<JsonEditor value={value} onChange={setValue} minHeight="220px" maxHeight="360px" />
<Typography.Text type={parseError ? 'danger' : 'success'}>
{parseError ? `Parse error: ${parseError}` : `Valid JSON (${value.length} chars)`}
</Typography.Text>
</div>
);
}
const meta = {
title: 'Form/JsonEditor',
component: JsonEditor,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'CodeMirror-based JSON editor with syntax highlighting, live parse linting, and theme-aware styling. The panel uses it for raw xray config snippets — inbound settings, stream settings, and outbound JSON in the modals and settings pages.',
},
},
},
decorators: [
(Story) => (
<ThemeProvider>
<Story />
</ThemeProvider>
),
],
argTypes: {
value: { description: 'JSON document text; the editor resyncs when this prop changes.' },
onChange: { description: 'Called with the full document text on every edit.' },
minHeight: { description: 'CSS min-height of the scrollable editor area.' },
maxHeight: { description: 'CSS max-height before the editor scrolls internally.' },
readOnly: { description: 'Disables editing while keeping selection and scrolling.' },
},
} satisfies Meta<typeof JsonEditor>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { value: warpOutbound },
};
export const ReadOnly: Story = {
args: { value: realityStreamSettings, readOnly: true, minHeight: '200px' },
};
export const LintErrors: Story = {
args: { value: brokenInboundSettings, minHeight: '220px' },
};
export const Controlled: Story = {
args: { value: '' },
parameters: {
a11y: {
config: {
rules: [{ id: 'scrollable-region-focusable', enabled: false }],
},
},
},
render: () => <ControlledDemo />,
};
@@ -120,6 +120,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
doc: value,
extensions: [
basicSetup,
EditorView.contentAttributes.of({ 'aria-label': t('jsonEditor') }),
keymap.of([indentWithTab]),
json(),
linter(jsonParseLinter()),
@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import RemarkTemplateField from './RemarkTemplateField';
const meta = {
title: 'Form/RemarkTemplateField',
component: RemarkTemplateField,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Text input augmented with a {{VAR}} token picker (insert-at-caret) and a live sample-based preview of the expanded remark. The panel uses it in subscription settings for the global Remark Template.',
},
},
},
argTypes: {
value: { description: 'Current template string; any {{VAR}} token enables the live preview below the input.' },
onChange: { description: 'Called with the updated template on typing or token insertion.' },
maxLength: { description: 'Maximum template length; picker insertions are clamped to it.' },
placeholder: { description: 'Placeholder shown while the template is empty.' },
},
} satisfies Meta<typeof RemarkTemplateField>;
export default meta;
type Story = StoryObj<typeof meta>;
function InteractiveDemo() {
const [value, setValue] = useState('{{STATUS_EMOJI}} {{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}}');
return <RemarkTemplateField value={value} onChange={setValue} maxLength={256} placeholder="{{INBOUND}}-{{EMAIL}}" />;
}
export const Empty: Story = {
args: {
value: '',
onChange: () => undefined,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const TokenTemplate: Story = {
args: {
value: '{{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}} left | {{DAYS_LEFT}}d',
onChange: () => undefined,
maxLength: 256,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const PlainRemark: Story = {
args: {
value: 'Germany CDN node',
onChange: () => undefined,
maxLength: 256,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const Interactive: Story = {
args: {
value: '',
onChange: () => undefined,
},
render: () => <InteractiveDemo />,
};
@@ -0,0 +1,78 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button, Input, Popover, Typography } from 'antd';
import { previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
import RemarkVarPicker from './RemarkVarPicker';
const meta = {
title: 'Form/RemarkVarPicker',
component: RemarkVarPicker,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grouped, tooltipped chip list of the {{VAR}} tokens the backend substitutes per client in subscription remarks. The hosts page shows it in a popover beside the remark-template field so operators can insert placeholders like {{EMAIL}} or {{TRAFFIC_LEFT}}.',
},
},
},
argTypes: {
onPick: { description: 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.' },
},
} satisfies Meta<typeof RemarkVarPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
function TemplateBuilderDemo() {
const [template, setTemplate] = useState('{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left');
return (
<div style={{ maxWidth: 520 }}>
<Input
value={template}
onChange={(e) => setTemplate(e.target.value)}
aria-label="Remark template"
style={{ fontFamily: 'monospace' }}
/>
<Typography.Paragraph type="secondary" style={{ margin: '8px 0 16px' }}>
Preview: {previewRemark(template)}
</Typography.Paragraph>
<RemarkVarPicker onPick={(token) => setTemplate((prev) => `${prev}${wrapToken(token)}`)} />
</div>
);
}
function PopoverDemo() {
const [lastPicked, setLastPicked] = useState('');
return (
<>
<Popover
content={<RemarkVarPicker onPick={(token) => setLastPicked(wrapToken(token))} />}
trigger="click"
placement="bottomRight"
title="Remark variables"
>
<Button>Insert variable</Button>
</Popover>
<div style={{ marginTop: 12 }}>Last picked: {lastPicked || '—'}</div>
</>
);
}
export const Default: Story = {
args: { onPick: () => undefined },
};
export const TemplateBuilder: Story = {
args: { onPick: () => undefined },
render: () => <TemplateBuilderDemo />,
};
export const InsidePopover: Story = {
args: { onPick: () => undefined },
render: () => <PopoverDemo />,
};
@@ -0,0 +1,132 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import { Select } from 'antd';
import SelectAllClearButtons from './SelectAllClearButtons';
const inboundOptions: Array<{ value: number; label: string }> = [
{ value: 1, label: 'VLESS Reality — 443' },
{ value: 2, label: 'VMess WS — 8443' },
{ value: 3, label: 'Trojan TCP — 2053' },
{ value: 4, label: 'Shadowsocks — 8388' },
];
const clientEmailOptions: Array<{ value: string; label: string }> = [
{ value: 'ava@corp.example', label: 'ava@corp.example' },
{ value: 'reza.mobile', label: 'reza.mobile' },
{ value: 'office-tv', label: 'office-tv' },
{ value: 'guest-42', label: 'guest-42' },
];
const meta = {
title: 'Form/SelectAllClearButtons',
component: SelectAllClearButtons,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Small "Select all" / "Clear all" button pair rendered above a multi-select. The panel places it over the attached-inbounds picker in the client form and the bulk attach/detach modals.',
},
},
},
argTypes: {
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.' },
},
} satisfies Meta<typeof SelectAllClearButtons>;
export default meta;
type Story = StoryObj<typeof meta>;
function InboundPickerDemo() {
const [selected, setSelected] = useState<number[]>([1]);
return (
<div style={{ maxWidth: 360 }}>
<SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
<Select
mode="multiple"
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={inboundOptions}
placeholder="Select inbounds"
aria-label="Select inbounds"
maxTagCount="responsive"
/>
</div>
);
}
function ClientEmailsDemo() {
const [selected, setSelected] = useState<string[]>(['ava@corp.example']);
return (
<div style={{ maxWidth: 360 }}>
<SelectAllClearButtons
options={clientEmailOptions}
value={selected}
onChange={setSelected}
selectAllLabel="Select all clients"
clearLabel="Deselect clients"
/>
<Select
mode="multiple"
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={clientEmailOptions}
placeholder="Select clients"
aria-label="Select clients"
maxTagCount="responsive"
/>
</div>
);
}
const placeholderArgs = {
options: [],
value: [],
onChange: () => undefined,
};
export const PartiallySelected: Story = {
args: {
options: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }],
value: [1, 3],
onChange: () => undefined,
},
};
export const AllSelected: Story = {
args: {
options: [{ value: 1 }, { value: 2 }, { value: 3 }],
value: [1, 2, 3],
onChange: () => undefined,
},
};
export const WithInboundSelect: Story = {
args: placeholderArgs,
render: () => <InboundPickerDemo />,
};
export const CustomLabels: Story = {
args: placeholderArgs,
render: () => <ClientEmailsDemo />,
play: async ({ canvas, userEvent }) => {
const selectAll = canvas.getByRole('button', { name: 'Select all clients' });
const clearAll = canvas.getByRole('button', { name: 'Deselect clients' });
await expect(selectAll).toBeEnabled();
await userEvent.click(selectAll);
await expect(selectAll).toBeDisabled();
await userEvent.click(clearAll);
await expect(clearAll).toBeDisabled();
await expect(selectAll).toBeEnabled();
},
};
+1
View File
@@ -1,6 +1,7 @@
export { default as DateTimePicker } from './DateTimePicker';
export { default as JsonEditor } from './JsonEditor';
export { default as HeaderMapEditor } from './HeaderMapEditor';
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
export { default as RemarkTemplateField } from './RemarkTemplateField';
export { default as RemarkVarPicker } from './RemarkVarPicker';
@@ -0,0 +1,210 @@
import { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
import { FormProvider } from 'react-hook-form';
import { z } from 'zod';
import { FormField } from './FormField';
import { useZodForm } from './useZodForm';
const GB = 1024 * 1024 * 1024;
const meta = {
title: 'Form/RHF/FormField',
component: FormField,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Bridges one Ant Design control into react-hook-form: wraps the child in a Controller plus Form.Item, normalizes the onChange payload, and surfaces resolver errors as translated help text. Every RHF panel form (client, inbound, outbound, and host modals) builds its fields with it.',
},
},
},
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.' },
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.' },
onAfterChange: { description: 'Called with the stored value after every change.' },
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.' },
},
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
const ClientSchema = z.object({
email: z.string(),
flow: z.string(),
enable: z.boolean(),
});
function ClientDemo() {
const methods = useZodForm(ClientSchema, {
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
});
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField name="email" label="Email" tooltip="Unique identifier used to match client traffic" required>
<Input placeholder="user1@example.com" />
</FormField>
<FormField name="flow" label="Flow" extra="Only applies to VLESS over raw TLS">
<Select
options={[
{ label: 'none', value: '' },
{ label: 'xtls-rprx-vision', value: 'xtls-rprx-vision' },
]}
/>
</FormField>
<FormField name="enable" label="Enable" valueProp="checked">
<Switch />
</FormField>
</Form>
</FormProvider>
);
}
const TrafficSchema = z.object({
totalBytes: z.number(),
});
function TrafficDemo() {
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
const totalBytes = methods.watch('totalBytes');
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField
name="totalBytes"
label="Total traffic (GB)"
extra="Stored on the client as bytes; 0 means unlimited"
transform={{
input: (value) => (typeof value === 'number' ? value / GB : value),
output: (value) => (typeof value === 'number' ? value * GB : 0),
}}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Typography.Text type="secondary">Form state: {totalBytes.toLocaleString()} bytes</Typography.Text>
</Form>
</FormProvider>
);
}
const InboundSchema = z.object({
remark: z.string().min(1, 'Remark is required'),
port: z
.number()
.min(1, 'Port must be between 1 and 65535')
.max(65535, 'Port must be between 1 and 65535'),
});
function ValidationDemo() {
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
useEffect(() => {
void methods.trigger();
}, [methods]);
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField name="remark" label="Remark" required>
<Input placeholder="vless-reality-443" />
</FormField>
<FormField name="port" label="Port" required>
<InputNumber style={{ width: '100%' }} />
</FormField>
<Button onClick={() => void methods.trigger()}>Validate</Button>
</Form>
</FormProvider>
);
}
const RealitySchema = z.object({
streamSettings: z.object({
realitySettings: z.object({
dest: z.string(),
serverNames: z.string(),
}),
}),
});
function NestedDemo() {
const methods = useZodForm(RealitySchema, {
defaultValues: {
streamSettings: {
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
},
},
});
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField
name={['streamSettings', 'realitySettings', 'dest']}
label="Dest"
tooltip="Camouflage target the REALITY handshake is proxied to"
>
<Input />
</FormField>
<FormField
name="streamSettings.realitySettings.serverNames"
label="Server names"
extra="Comma-separated SNI list offered to clients"
>
<Input />
</FormField>
</Form>
</FormProvider>
);
}
const placeholderArgs = {
name: 'email',
children: <Input />,
};
export const ClientFields: Story = {
args: placeholderArgs,
render: () => <ClientDemo />,
};
export const TrafficTransform: Story = {
args: placeholderArgs,
render: () => <TrafficDemo />,
play: async ({ canvas, userEvent }) => {
const input = canvas.getByRole('spinbutton');
await userEvent.clear(input);
await userEvent.type(input, '100');
await expect(await canvas.findByText(/107,374,182,400 bytes/)).toBeVisible();
},
};
export const ValidationErrors: Story = {
args: placeholderArgs,
render: () => <ValidationDemo />,
play: async ({ canvas, userEvent }) => {
const remarkError = await canvas.findByText('Remark is required');
await waitFor(() => expect(remarkError).toBeVisible());
await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
await userEvent.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
await waitFor(() => expect(canvas.queryByText('Remark is required')).not.toBeInTheDocument());
await expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible();
},
};
export const NestedNames: Story = {
args: placeholderArgs,
render: () => <NestedDemo />,
};
@@ -1,4 +1,4 @@
import { cloneElement } from 'react';
import { cloneElement, useId } from 'react';
import type { CSSProperties, ReactElement, ReactNode } from 'react';
import { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
@@ -48,6 +48,9 @@ export function FormField<T extends FieldValues = FieldValues>({
}: FormFieldProps<T>) {
const { t } = useTranslation();
const dottedName = toDotted(name) as Path<T>;
const generatedId = useId();
const explicitId = (children as ReactElement<{ id?: string }>).props.id;
const fieldId = explicitId ?? generatedId;
return (
<Controller
@@ -60,6 +63,7 @@ export function FormField<T extends FieldValues = FieldValues>({
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined;
const childProps: Record<string, unknown> = {
id: fieldId,
[valueProp]: displayValue,
onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp);
@@ -73,6 +77,7 @@ export function FormField<T extends FieldValues = FieldValues>({
return (
<Form.Item
label={label}
htmlFor={label ? fieldId : undefined}
tooltip={tooltip}
extra={extra}
required={required}
@@ -6,6 +6,18 @@ const meta = {
title: 'UI/InfinityIcon',
component: InfinityIcon,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Inline SVG infinity glyph used to denote an unlimited value (e.g. unlimited traffic or no expiry). Inherits the current text color.',
},
},
},
argTypes: {
width: { description: 'Icon width in pixels or any CSS length.' },
height: { description: 'Icon height in pixels or any CSS length.' },
},
} satisfies Meta<typeof InfinityIcon>;
export default meta;
@@ -7,6 +7,21 @@ const meta = {
title: 'UI/InputAddon',
component: InputAddon,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Prefix/suffix addon styled to sit flush against an Ant Design input. Becomes a keyboard-accessible button (role, tabIndex, Enter/Space) when `onClick` is provided.',
},
},
},
argTypes: {
children: { description: 'Addon content (text or an icon).' },
onClick: { description: 'When set, the addon becomes an activatable button.' },
ariaLabel: { description: 'Accessible label; used only when `onClick` is set.' },
className: { description: 'Extra CSS class appended to the addon.' },
style: { description: 'Inline styles for the addon element.' },
},
} satisfies Meta<typeof InputAddon>;
export default meta;
@@ -26,7 +41,7 @@ export const BesideInput: Story = {
render: () => (
<Space.Compact>
<InputAddon>https://</InputAddon>
<Input defaultValue="panel.example.com" style={{ width: 220 }} />
<Input defaultValue="panel.example.com" aria-label="Panel host" style={{ width: 220 }} />
</Space.Compact>
),
};
@@ -7,7 +7,22 @@ const meta = {
title: 'UI/SettingListItem',
component: SettingListItem,
tags: ['autodocs'],
parameters: { layout: 'padded' },
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Two-column settings row: a title and description on the left, and a control (Switch, InputNumber, …) on the right. Associates the title with the control via `aria-labelledby` for accessibility.',
},
},
},
argTypes: {
title: { description: 'Setting name shown on the left.' },
description: { description: 'Secondary help text under the title.' },
control: { description: 'The control rendered on the right (Switch, InputNumber, …).' },
children: { description: 'Alternative to `control`; used when no explicit control is passed.' },
paddings: { description: 'Row density: `default` or the tighter `small`.' },
},
} satisfies Meta<typeof SettingListItem>;
export default meta;
@@ -0,0 +1,85 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AllSetting } from '@/models/setting';
import { EmailNotifications } from './EmailNotifications';
const meta = {
title: 'UI/Notifications/EmailNotifications',
component: EmailNotifications,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grid of grouped event checkboxes on the settings page that picks which panel events (outbound/node health, Xray crashes, CPU/RAM thresholds, login attempts) trigger an SMTP email, stored as a comma-separated list in smtpEnabledEvents.',
},
},
},
argTypes: {
allSetting: {
description:
'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
},
updateSetting: {
description: 'Receives a partial settings patch when an event is toggled or a threshold input changes.',
},
},
} satisfies Meta<typeof EmailNotifications>;
export default meta;
type Story = StoryObj<typeof meta>;
function StatefulDemo({ initial }: { initial: AllSetting }) {
const [settings, setSettings] = useState(initial);
return (
<EmailNotifications
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
const placeholderArgs = {
allSetting: new AllSetting(),
updateSetting: () => undefined,
};
export const NothingSelected: Story = {
args: placeholderArgs,
render: () => <StatefulDemo initial={new AllSetting()} />,
};
export const SystemThresholdAlerts: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })}
/>
),
};
export const InfrastructureOnly: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })} />
),
};
export const AllEventsEnabled: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={
new AllSetting({
smtpEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
smtpCpu: 80,
smtpMemory: 80,
})
}
/>
),
};
@@ -37,16 +37,16 @@ const GROUPS: NotificationGroupConfig[] = [
key: 'cpu.high',
label: 'eventCPUHigh',
settingKey: 'smtpCpu',
extra: ({ value, onChange }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
{
key: 'memory.high',
label: 'eventMemoryHigh',
settingKey: 'smtpMemory',
extra: ({ value, onChange }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
],
@@ -8,7 +8,21 @@ const meta = {
title: 'UI/Notifications/NotificationCard',
component: NotificationCard,
tags: ['autodocs'],
parameters: { layout: 'padded' },
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Small outlined card that groups a notification channel — an icon and title in the header, a control in the top-right `extra` slot (typically a toggle), and the channel settings as its body.',
},
},
},
argTypes: {
icon: { description: 'Leading icon shown before the title.' },
title: { description: 'Channel name shown in the header.' },
extra: { description: 'Top-right slot, typically an enable/disable Switch.' },
children: { description: 'Card body — the channel settings.' },
},
} satisfies Meta<typeof NotificationCard>;
export default meta;
@@ -19,7 +33,7 @@ export const Default: Story = {
args: {
icon: <BellOutlined />,
title: 'Telegram',
extra: <Switch defaultChecked />,
extra: <Switch defaultChecked aria-label="Enable Telegram notifications" />,
children: <span>Push a message to the configured chat when an event fires.</span>,
},
};
@@ -28,7 +42,7 @@ export const Disabled: Story = {
args: {
icon: <BellOutlined />,
title: 'Email',
extra: <Switch />,
extra: <Switch aria-label="Enable email notifications" />,
children: <span>Email delivery is turned off for this channel.</span>,
},
};
@@ -0,0 +1,77 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber } from 'antd';
import { NotificationEvent } from './NotificationEvent';
const meta = {
title: 'UI/Notifications/NotificationEvent',
component: NotificationEvent,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Single toggleable notification event row used inside the Telegram and email notification groups on the settings page. Renders a checkbox with a translated label and, when checked, an optional indented extra control such as a threshold input.',
},
},
},
argTypes: {
label: { description: 'i18n key (or already-translated text) shown next to the checkbox.' },
checked: { description: 'Whether the event notification is enabled.' },
onToggle: { description: 'Called when the checkbox is clicked.' },
children: { description: 'Extra control rendered indented below the label while checked.' },
},
} satisfies Meta<typeof NotificationEvent>;
export default meta;
type Story = StoryObj<typeof meta>;
function CpuThresholdDemo() {
const [checked, setChecked] = useState(true);
const [threshold, setThreshold] = useState(80);
return (
<NotificationEvent
label="pages.settings.eventCPUHigh"
checked={checked}
onToggle={() => setChecked((prev) => !prev)}
>
<InputNumber
size="small"
min={0}
max={100}
value={threshold}
onChange={(v) => setThreshold(v ?? 0)}
aria-label="CPU usage threshold percent"
style={{ width: 80 }}
/>
</NotificationEvent>
);
}
export const Unchecked: Story = {
args: {
label: 'pages.settings.eventLoginAttempt',
checked: false,
onToggle: () => undefined,
},
};
export const Checked: Story = {
args: {
label: 'pages.settings.eventXrayCrash',
checked: true,
onToggle: () => undefined,
},
};
export const CpuThreshold: Story = {
args: {
label: 'pages.settings.eventCPUHigh',
checked: true,
onToggle: () => undefined,
},
render: () => <CpuThresholdDemo />,
};
@@ -0,0 +1,131 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber } from 'antd';
import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
import { AllSetting } from '@/models/setting';
import { NotificationGroup } from './NotificationGroup';
import type { NotificationGroupConfig } from './types';
const systemGroup: NotificationGroupConfig = {
icon: <DashboardOutlined />,
title: 'eventGroupSystem',
events: [
{
key: 'cpu.high',
label: 'eventCPUHigh',
settingKey: 'tgCpu',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
{
key: 'memory.high',
label: 'eventMemoryHigh',
settingKey: 'tgMemory',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
],
};
const outboundGroup: NotificationGroupConfig = {
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
],
};
const meta = {
title: 'UI/Notifications/NotificationGroup',
component: NotificationGroup,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Card for one notification event group (outbound, Xray, node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
},
},
},
argTypes: {
config: { description: 'Group definition: icon, `pages.settings` title key, and the event rows to render.' },
selected: { description: 'Enabled event keys; drives each checkbox and the header count.' },
onToggle: { description: 'Called with the event key when a single checkbox is clicked.' },
onToggleAll: { description: 'Called with every event key in the group when the master checkbox is clicked.' },
allSetting: { description: 'Panel settings snapshot; threshold values such as `tgCpu` are read from it.' },
updateSetting: { description: 'Called with a partial settings patch when a threshold input changes.' },
},
} satisfies Meta<typeof NotificationGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
function Demo() {
const [selected, setSelected] = useState<string[]>(['cpu.high']);
const [settings, setSettings] = useState(new AllSetting({ tgCpu: 85, tgMemory: 90 }));
return (
<NotificationGroup
config={systemGroup}
selected={selected}
onToggle={(key) =>
setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]))
}
onToggleAll={(keys) =>
setSelected((prev) => (keys.every((k) => prev.includes(k)) ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])]))
}
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
export const AllSelected: Story = {
args: {
config: systemGroup,
selected: ['cpu.high', 'memory.high'],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting({ tgCpu: 85, tgMemory: 90 }),
updateSetting: () => undefined,
},
};
export const PartiallySelected: Story = {
args: {
config: systemGroup,
selected: ['cpu.high'],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
};
export const NoneSelected: Story = {
args: {
config: outboundGroup,
selected: [],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
};
export const Interactive: Story = {
args: {
config: systemGroup,
selected: [],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
render: () => <Demo />,
};
@@ -51,6 +51,7 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
{event.extra?.({
value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
onChange: (v) => updateSetting({ [event.settingKey]: v }),
ariaLabel: t(`pages.settings.${event.label}`),
})}
</NotificationEvent>
))}
@@ -0,0 +1,104 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Checkbox } from 'antd';
import { NotificationHeader } from './NotificationHeader';
const meta = {
title: 'UI/Notifications/NotificationHeader',
component: NotificationHeader,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Selection summary for a notification group header — a `count/total` tag plus a tri-state master checkbox that selects or clears every event in the group. Rendered in the `extra` slot of the Telegram/email notification cards on the settings page.',
},
},
},
argTypes: {
count: { description: 'Number of events currently selected in the group.' },
total: { description: 'Total number of events the group offers.' },
allSelected: { description: 'Checks the master checkbox when every event is selected.' },
indeterminate: { description: 'Shows the dash state when only some events are selected.' },
onToggleAll: { description: 'Called when the master checkbox is clicked to select or clear all events.' },
},
} satisfies Meta<typeof NotificationHeader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NoneSelected: Story = {
args: {
count: 0,
total: 6,
allSelected: false,
indeterminate: false,
onToggleAll: () => undefined,
},
};
export const PartialSelection: Story = {
args: {
count: 3,
total: 6,
allSelected: false,
indeterminate: true,
onToggleAll: () => undefined,
},
};
export const AllSelected: Story = {
args: {
count: 6,
total: 6,
allSelected: true,
indeterminate: false,
onToggleAll: () => undefined,
},
};
const events = ['Panel login', 'Xray crashed', 'CPU high', 'Client depleted'];
function GroupDemo() {
const [selected, setSelected] = useState<string[]>(['Panel login', 'CPU high']);
const count = selected.length;
const total = events.length;
function toggleAll() {
setSelected(count === total ? [] : [...events]);
}
function toggle(name: string) {
setSelected((prev) => (prev.includes(name) ? prev.filter((e) => e !== name) : [...prev, name]));
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
<NotificationHeader
count={count}
total={total}
allSelected={count === total}
indeterminate={count > 0 && count < total}
onToggleAll={toggleAll}
/>
{events.map((name) => (
<Checkbox key={name} checked={selected.includes(name)} onChange={() => toggle(name)}>
{name}
</Checkbox>
))}
</div>
);
}
const placeholderArgs = {
count: 0,
total: 0,
allSelected: false,
indeterminate: false,
onToggleAll: () => undefined,
};
export const Interactive: Story = {
args: placeholderArgs,
render: () => <GroupDemo />,
};
@@ -0,0 +1,146 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber, Space } from 'antd';
import {
CloudServerOutlined,
DashboardOutlined,
DesktopOutlined,
SafetyOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { NotificationLayout } from './NotificationLayout';
import { NotificationCard } from './NotificationCard';
import { NotificationEvent } from './NotificationEvent';
import { NotificationHeader } from './NotificationHeader';
const noop = () => undefined;
function OutboundGroup() {
return (
<NotificationCard
icon={<CloudServerOutlined />}
title="Outbound"
extra={<NotificationHeader count={1} total={2} allSelected={false} indeterminate onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Outbound went down" checked onToggle={noop} />
<NotificationEvent label="Outbound recovered" checked={false} onToggle={noop} />
</Space>
</NotificationCard>
);
}
function XrayGroup() {
return (
<NotificationCard
icon={<ThunderboltOutlined />}
title="Xray"
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Xray crashed" checked onToggle={noop} />
</Space>
</NotificationCard>
);
}
function NodeGroup() {
return (
<NotificationCard
icon={<DesktopOutlined />}
title="Nodes"
extra={<NotificationHeader count={0} total={2} allSelected={false} indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Node went offline" checked={false} onToggle={noop} />
<NotificationEvent label="Node back online" checked={false} onToggle={noop} />
</Space>
</NotificationCard>
);
}
function SystemGroup() {
return (
<NotificationCard
icon={<DashboardOutlined />}
title="System"
extra={<NotificationHeader count={2} total={2} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="CPU usage above threshold (%)" checked onToggle={noop}>
<InputNumber size="small" min={0} max={100} defaultValue={80} aria-label="CPU usage threshold percent" style={{ width: 80 }} />
</NotificationEvent>
<NotificationEvent label="Memory usage above threshold (%)" checked onToggle={noop}>
<InputNumber size="small" min={0} max={100} defaultValue={90} aria-label="Memory usage threshold percent" style={{ width: 80 }} />
</NotificationEvent>
</Space>
</NotificationCard>
);
}
function SecurityGroup() {
return (
<NotificationCard
icon={<SafetyOutlined />}
title="Security"
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Panel login attempt" checked onToggle={noop} />
</Space>
</NotificationCard>
);
}
const meta = {
title: 'UI/Notifications/NotificationLayout',
component: NotificationLayout,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Responsive auto-fit grid (min 260px columns) that arranges notification event-group cards; the Telegram and email notification tabs on the settings page render their groups inside it.',
},
},
},
argTypes: {
children: { description: 'Grid items, typically one NotificationCard per event group.' },
},
} satisfies Meta<typeof NotificationLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllEventGroups: Story = {
args: {
children: (
<>
<OutboundGroup />
<XrayGroup />
<NodeGroup />
<SystemGroup />
<SecurityGroup />
</>
),
},
};
export const TwoGroups: Story = {
args: {
children: (
<>
<SystemGroup />
<SecurityGroup />
</>
),
},
};
export const SingleGroup: Story = {
args: {
children: <OutboundGroup />,
},
};
@@ -0,0 +1,82 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AllSetting } from '@/models/setting';
import { TelegramNotifications } from './TelegramNotifications';
const meta = {
title: 'UI/Notifications/TelegramNotifications',
component: TelegramNotifications,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grid of event-group cards (outbound, Xray, node, system, security) that pick which panel events the Telegram bot reports, with per-group select-all and CPU/RAM threshold inputs. Used on the settings page Telegram tab to edit `tgEnabledEvents`.',
},
},
},
argTypes: {
allSetting: { description: 'Panel settings snapshot; reads `tgEnabledEvents` plus the `tgCpu`/`tgMemory` thresholds.' },
updateSetting: { description: 'Called with a partial settings patch when an event toggle or threshold changes.' },
},
} satisfies Meta<typeof TelegramNotifications>;
export default meta;
type Story = StoryObj<typeof meta>;
function Demo({ initial }: { initial: AllSetting }) {
const [settings, setSettings] = useState(initial);
return (
<TelegramNotifications
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
const placeholderArgs = {
allSetting: new AllSetting(),
updateSetting: () => undefined,
};
export const NothingSelected: Story = {
args: placeholderArgs,
render: () => <Demo initial={new AllSetting()} />,
};
export const TypicalMonitoring: Story = {
args: placeholderArgs,
render: () => (
<Demo
initial={
new AllSetting({
tgBotEnable: true,
tgBotChatId: '123456789',
tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
tgCpu: 85,
tgMemory: 90,
})
}
/>
),
};
export const EverythingEnabled: Story = {
args: placeholderArgs,
render: () => (
<Demo
initial={
new AllSetting({
tgBotEnable: true,
tgEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
tgCpu: 70,
tgMemory: 75,
})
}
/>
),
};
@@ -37,16 +37,16 @@ const GROUPS: NotificationGroupConfig[] = [
key: 'cpu.high',
label: 'eventCPUHigh',
settingKey: 'tgCpu',
extra: ({ value, onChange }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
{
key: 'memory.high',
label: 'eventMemoryHigh',
settingKey: 'tgMemory',
extra: ({ value, onChange }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
],
@@ -4,7 +4,7 @@ export interface NotificationEventConfig {
key: string;
label: string;
settingKey: string;
extra?: (props: { value: number; onChange: (v: number | null) => void }) => ReactNode;
extra?: (props: { value: number; onChange: (v: number | null) => void; ariaLabel: string }) => ReactNode;
}
export interface NotificationGroupConfig {
@@ -0,0 +1,138 @@
import { lazy, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Alert, Button, Card, Skeleton, Space, Switch, Tag, Typography } from 'antd';
import LazyMount from './LazyMount';
const meta = {
title: 'Utility/LazyMount',
component: LazyMount,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Mounts its children the first time `when` becomes true and keeps them mounted afterwards, wrapped in Suspense. The panel pairs it with React.lazy modal imports on heavy list pages so modals load on demand while their close animations still play.',
},
},
},
argTypes: {
when: { description: 'Children mount the first time this becomes true and stay mounted afterwards.' },
fallback: { description: 'Suspense fallback shown while a React.lazy child is still loading.' },
children: { description: 'Content to mount on demand, typically a lazily imported modal.' },
},
} satisfies Meta<typeof LazyMount>;
export default meta;
type Story = StoryObj<typeof meta>;
function MountBadge() {
const [mountedAt] = useState(() => new Date().toLocaleTimeString());
return <Tag color="green">mounted at {mountedAt}</Tag>;
}
function OnDemandDemo() {
const [visible, setVisible] = useState(false);
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space>
<Switch checked={visible} onChange={setVisible} aria-label="Mount the client card" />
<Typography.Text>when = {String(visible)}</Typography.Text>
</Space>
<LazyMount when={visible}>
<Card size="small" title="alice@corp.example" style={{ maxWidth: 480 }}>
<Space orientation="vertical" size={4}>
<MountBadge />
<Typography.Text type="secondary">Traffic: 42.7 GB of 100 GB</Typography.Text>
<Typography.Text code copyable style={{ fontSize: 12 }}>
vless://b831381d-6324-4d53-ad4f-8cda48b30811@vpn.example.com:443?type=tcp&security=reality&fp=chrome&sni=yahoo.com#alice
</Typography.Text>
</Space>
</Card>
</LazyMount>
<Typography.Text type="secondary">
The card mounts the first time the switch turns on and stays mounted after turning it off; the mount time never changes.
</Typography.Text>
</Space>
);
}
const xrayConfigSnippet = JSON.stringify(
{
inbounds: [
{
tag: 'inbound-443',
protocol: 'vless',
port: 443,
settings: {
clients: [{ id: 'b831381d-6324-4d53-ad4f-8cda48b30811', email: 'alice@corp.example', flow: 'xtls-rprx-vision' }],
decryption: 'none',
},
streamSettings: { network: 'tcp', security: 'reality' },
},
],
},
null,
2,
);
function XrayConfigPreview() {
return (
<Card size="small" title="Generated xray config">
<pre style={{ margin: 0, overflowX: 'auto', fontSize: 12 }}>{xrayConfigSnippet}</pre>
</Card>
);
}
const SlowXrayConfigPreview = lazy(
() =>
new Promise<{ default: typeof XrayConfigPreview }>((resolve) => {
setTimeout(() => resolve({ default: XrayConfigPreview }), 1200);
}),
);
function LazyChildDemo() {
const [open, setOpen] = useState(false);
return (
<Space orientation="vertical" size="middle" style={{ width: '100%', maxWidth: 560 }}>
<Button type="primary" onClick={() => setOpen(true)} disabled={open}>
Load config preview
</Button>
<LazyMount when={open} fallback={<Skeleton active paragraph={{ rows: 4 }} />}>
<SlowXrayConfigPreview />
</LazyMount>
</Space>
);
}
const placeholderArgs = {
when: false,
children: null,
};
export const MountOnDemand: Story = {
args: placeholderArgs,
render: () => <OnDemandDemo />,
};
export const LazyChildWithFallback: Story = {
args: placeholderArgs,
render: () => <LazyChildDemo />,
};
export const MountedImmediately: Story = {
args: {
when: true,
children: (
<Alert
type="warning"
showIcon
title="Client depleted"
description="bob@corp.example used 100 GB of 100 GB and was disabled by the traffic job."
style={{ maxWidth: 480 }}
/>
),
},
};
@@ -0,0 +1,21 @@
import type { CSSProperties } from 'react';
export const SPEED_TAG_CLASS_NAME = 'table-speed-tag' as const;
export const SPEED_TAG_WIDTH = 200 as const;
export const SPEED_TABLE_CELL_INLINE_PADDING = 8 as const;
export const SPEED_TAG_STYLE = {
width: SPEED_TAG_WIDTH,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
marginInlineEnd: 0,
boxSizing: 'border-box',
overflow: 'hidden',
textOverflow: 'ellipsis',
} as const satisfies CSSProperties;
export const SPEED_COLUMN_WIDTH = SPEED_TAG_WIDTH + SPEED_TABLE_CELL_INLINE_PADDING * 2;
@@ -9,7 +9,27 @@ const meta = {
title: 'Viz/Sparkline',
component: Sparkline,
tags: ['autodocs'],
parameters: { layout: 'padded' },
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Compact canvas line chart (uPlot) for CPU, memory, and traffic trends. Supports up to three series, optional axes/grid, a hover tooltip, min/max markers, reference lines, and light/dark theming.',
},
},
},
argTypes: {
data: { description: 'Primary series values, oldest to newest.' },
data2: { description: 'Optional second series (e.g. download vs upload).' },
data3: { description: 'Optional third series.' },
height: { description: 'Chart height in pixels.' },
name1: { description: 'Legend/tooltip label for the primary series.' },
name2: { description: 'Legend/tooltip label for the second series.' },
showAxes: { description: 'Render x/y axes and tick labels.' },
showGrid: { description: 'Draw horizontal grid lines.' },
showTooltip: { description: 'Show a value tooltip on hover.' },
extrema: { description: 'Highlight the min and max points (single-series only).' },
},
} satisfies Meta<typeof Sparkline>;
export default meta;
+14
View File
@@ -35,6 +35,8 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
@@ -43,11 +45,13 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEnableRouting": false,
@@ -55,12 +59,15 @@ export const EXAMPLES: Record<string, unknown> = {
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
@@ -138,6 +145,8 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
@@ -146,11 +155,13 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEnableRouting": false,
@@ -158,12 +169,15 @@ export const EXAMPLES: Record<string, unknown> = {
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
+56
View File
@@ -116,6 +116,12 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
@@ -144,6 +150,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subCertFile": {
"type": "string"
},
"subClashAutoDetect": {
"type": "boolean"
},
"subClashEnable": {
"type": "boolean"
},
@@ -159,6 +168,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashURI": {
"type": "string"
},
"subClashUserAgentRegex": {
"type": "string"
},
"subDomain": {
"type": "string"
},
@@ -180,6 +192,12 @@ export const SCHEMAS: Record<string, unknown> = {
"subIncyRoutingRules": {
"type": "string"
},
"subJsonAlwaysArray": {
"type": "boolean"
},
"subJsonAutoDetect": {
"type": "boolean"
},
"subJsonEnable": {
"type": "boolean"
},
@@ -198,6 +216,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonURI": {
"type": "string"
},
"subJsonUserAgentRegex": {
"type": "string"
},
"subKeyFile": {
"type": "string"
},
@@ -349,6 +370,8 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
@@ -357,11 +380,13 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
"subDomain",
"subEnable",
"subEnableRouting",
@@ -369,12 +394,15 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
"subJsonPath",
"subJsonRules",
"subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile",
"subListen",
"subPath",
@@ -549,6 +577,12 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
@@ -577,6 +611,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subCertFile": {
"type": "string"
},
"subClashAutoDetect": {
"type": "boolean"
},
"subClashEnable": {
"type": "boolean"
},
@@ -592,6 +629,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashURI": {
"type": "string"
},
"subClashUserAgentRegex": {
"type": "string"
},
"subDomain": {
"type": "string"
},
@@ -613,6 +653,12 @@ export const SCHEMAS: Record<string, unknown> = {
"subIncyRoutingRules": {
"type": "string"
},
"subJsonAlwaysArray": {
"type": "boolean"
},
"subJsonAutoDetect": {
"type": "boolean"
},
"subJsonEnable": {
"type": "boolean"
},
@@ -631,6 +677,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonURI": {
"type": "string"
},
"subJsonUserAgentRegex": {
"type": "string"
},
"subKeyFile": {
"type": "string"
},
@@ -789,6 +838,8 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
@@ -797,11 +848,13 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
"subDomain",
"subEnable",
"subEnableRouting",
@@ -809,12 +862,15 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
"subJsonPath",
"subJsonRules",
"subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile",
"subListen",
"subPath",
+14
View File
@@ -41,6 +41,8 @@ export interface AllSetting {
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
@@ -49,11 +51,13 @@ export interface AllSetting {
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEnableRouting: boolean;
@@ -61,12 +65,15 @@ export interface AllSetting {
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
subListen: string;
subPath: string;
@@ -145,6 +152,8 @@ export interface AllSettingView {
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
@@ -153,11 +162,13 @@ export interface AllSettingView {
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEnableRouting: boolean;
@@ -165,12 +176,15 @@ export interface AllSettingView {
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
subListen: string;
subPath: string;
+14
View File
@@ -53,6 +53,8 @@ export const AllSettingSchema = z.object({
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
@@ -61,11 +63,13 @@ export const AllSettingSchema = z.object({
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
@@ -73,12 +77,15 @@ export const AllSettingSchema = z.object({
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
@@ -158,6 +165,8 @@ export const AllSettingViewSchema = z.object({
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
@@ -166,11 +175,13 @@ export const AllSettingViewSchema = z.object({
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
@@ -178,12 +189,15 @@ export const AllSettingViewSchema = z.object({
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
+15
View File
@@ -78,13 +78,28 @@ const STATISTIC_TOKENS = {
contentFontSize: 17,
titleFontSize: 11,
};
const LIGHT_CONTRAST_TOKENS = {
colorTextDescription: 'rgba(0, 0, 0, 0.58)',
colorTextTertiary: 'rgba(0, 0, 0, 0.58)',
colorTextPlaceholder: '#767676',
colorError: '#cf1322',
colorErrorText: '#cf1322',
colorSuccessText: '#237804',
};
const LIGHT_BUTTON_TOKENS = {
colorPrimary: '#0958d9',
colorPrimaryHover: '#2468e5',
colorPrimaryActive: '#073ea8',
};
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
if (!isDark) {
return {
algorithm: antdTheme.defaultAlgorithm,
token: LIGHT_CONTRAST_TOKENS,
components: {
Statistic: STATISTIC_TOKENS,
Button: LIGHT_BUTTON_TOKENS,
},
};
}
+7
View File
@@ -29,6 +29,11 @@ export class AllSetting {
xrayTemplateConfig = '';
subEnable = true;
subJsonEnable = false;
subJsonAutoDetect = false;
subJsonAlwaysArray = false;
subJsonUserAgentRegex = '';
subClashAutoDetect = false;
subClashUserAgentRegex = '';
subTitle = '';
subSupportUrl = '';
subProfileUrl = '';
@@ -91,6 +96,8 @@ export class AllSetting {
smtpPort = 587;
smtpUsername = '';
smtpPassword = '';
smtpFrom = '';
smtpFromName = '';
smtpTo = '';
smtpEncryptionType = 'starttls';
smtpEnabledEvents = '';
+9 -2
View File
@@ -1145,7 +1145,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/setting/all',
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "subClashAutoDetect": false,\n "subClashUserAgentRegex": "",\n "subJsonEnable": false,\n "subJsonAutoDetect": false,\n "subJsonAlwaysArray": false,\n "subJsonUserAgentRegex": "",\n "subJsonPath": "/json/",\n "subJsonURI": "https://sub.example.com/json/",\n "subClashEnable": true,\n "subClashPath": "/clash/",\n "subClashURI": "https://sub.example.com/clash/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
},
{
method: 'POST',
@@ -1156,7 +1156,14 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/setting/update',
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "subClashAutoDetect": false,\n "subClashUserAgentRegex": "",\n "subJsonEnable": false,\n "subJsonAutoDetect": false,\n "subJsonAlwaysArray": false,\n "subJsonUserAgentRegex": "",\n "subJsonPath": "/json/",\n "subJsonURI": "https://sub.example.com/json/",\n "subClashEnable": true,\n "subClashPath": "/clash/",\n "subClashURI": "https://sub.example.com/clash/",\n "tgBotEnable": false,\n ...\n}',
},
{
method: 'POST',
path: '/panel/api/setting/validateRegex',
summary: 'Validate any regular expression with the backend Go RE2 compiler without saving it.',
body: '{\n "regex": "(?m)^general-purpose$"\n}',
response: '{\n "success": true,\n "msg": ""\n}',
},
{
method: 'POST',
@@ -96,7 +96,7 @@ export default function BulkAttachInboundsModal({
onChange={setTargetIds}
options={targetOptions}
placeholder={t('pages.clients.attachToInboundsTargets')}
optionFilterProp="label"
showSearch={{ optionFilterProp: 'label' }}
autoFocus
/>
</>
@@ -96,7 +96,7 @@ export default function BulkDetachInboundsModal({
onChange={setTargetIds}
options={targetOptions}
placeholder={t('pages.clients.detachFromInboundsTargets')}
optionFilterProp="label"
showSearch={{ optionFilterProp: 'label' }}
autoFocus
/>
</>
@@ -162,6 +162,17 @@
background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
}
.client-card-comment {
display: block;
margin-top: 6px;
color: var(--ant-color-text-secondary);
font-size: 12px;
max-height: 4.5em;
overflow-y: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.client-card-speed {
margin-top: 6px;
font-size: 12px;
+10 -5
View File
@@ -62,10 +62,12 @@ import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
import ClientSpeedTag, { isActiveSpeed } from '@/components/clients/ClientSpeedTag';
import ClientCardComment from '@/components/clients/ClientCardComment';
import AppSidebar from '@/layouts/AppSidebar';
import { IntlUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { LazyMount } from '@/components/utility';
import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
const ClientFormModal = lazy(() => import('./ClientFormModal'));
const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
const ClientQrModal = lazy(() => import('./ClientQrModal'));
@@ -818,7 +820,7 @@ export default function ClientsPage() {
<div className="email-cell">
<span className="email">{record.email}</span>
{record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
{record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
<ClientCardComment comment={record.comment} className="sub" />
</div>
),
},
@@ -907,12 +909,14 @@ export default function ClientsPage() {
{
title: t('pages.clients.speed'),
key: 'speed',
width: 110,
width: SPEED_COLUMN_WIDTH,
align: 'center',
render: (_v, record) => {
const speed = clientSpeed[record.email];
if (!isActiveSpeed(speed)) return <Tag color="default"></Tag>;
return <ClientSpeedTag speed={speed} />;
if (!isActiveSpeed(speed)) {
return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}></Tag>;
}
return <ClientSpeedTag speed={speed} tableCell />;
},
},
{
@@ -1212,7 +1216,7 @@ export default function ClientsPage() {
value={sortValueFor(sortColumn, sortOrder)}
aria-label={t('sort')}
size={isMobile ? 'small' : 'middle'}
suffixIcon={<SortAscendingOutlined />}
suffix={<SortAscendingOutlined />}
style={{ minWidth: isMobile ? 130 : 200 }}
onChange={(value) => {
const opt = SORT_OPTIONS.find((o) => o.value === value);
@@ -1433,6 +1437,7 @@ export default function ClientsPage() {
</Dropdown>
</div>
</div>
<ClientCardComment comment={row.comment} />
<ClientTrafficCell
compact
up={row.traffic?.up}
+4 -7
View File
@@ -89,7 +89,7 @@ export default function FilterDrawer({
title={t('pages.clients.filterTitle')}
open={open}
onClose={() => onOpenChange(false)}
width={420}
size={420}
destroyOnHidden
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
@@ -139,8 +139,7 @@ export default function FilterDrawer({
placeholder={t('inbounds')}
maxTagCount="responsive"
allowClear
showSearch
optionFilterProp="label"
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
/>
</Form.Item>
@@ -155,8 +154,7 @@ export default function FilterDrawer({
placeholder={t('pages.clients.filters.nodes')}
maxTagCount="responsive"
allowClear
showSearch
optionFilterProp="label"
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
/>
</Form.Item>
@@ -171,8 +169,7 @@ export default function FilterDrawer({
placeholder={t('pages.clients.groupPlaceholder')}
maxTagCount="responsive"
allowClear
showSearch
optionFilterProp="label"
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
/>
</Form.Item>
+2 -3
View File
@@ -191,8 +191,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
<Select
mode="multiple"
options={inboundSelectOptions}
showSearch
optionFilterProp="label"
showSearch={{ optionFilterProp: 'label' }}
placeholder={t('pages.hosts.selectInbound')}
/>
</FormField>
@@ -211,7 +210,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
<FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
<Select mode="multiple" allowClear options={nodeSelectOptions} showSearch={{ optionFilterProp: 'label' }} />
</FormField>
<FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
<Switch />
@@ -203,7 +203,7 @@ export default function AttachClientsModal({
onChange={setTargetIds}
options={targetOptions}
placeholder={t('pages.inbounds.attachClientsTargets')}
optionFilterProp="label"
showSearch={{ optionFilterProp: 'label' }}
/>
)}
</Modal>
@@ -203,7 +203,7 @@ export default function AttachExistingClientsModal({
options={groupOptions}
placeholder={t('pages.clients.group')}
style={{ minWidth: 160 }}
optionFilterProp="label"
showSearch={{ optionFilterProp: 'label' }}
/>
)}
</Space>
@@ -1,6 +1,7 @@
import { Tag, Tooltip } from 'antd';
import { SizeFormatter } from '@/utils';
import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
import type { InboundSpeedEntry } from './types';
@@ -12,12 +13,17 @@ export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedE
interface InboundSpeedTagProps {
speed: InboundSpeedEntry;
withTooltip?: boolean;
tableCell?: boolean;
}
// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
export function InboundSpeedTag({ speed, withTooltip = false, tableCell = false }: InboundSpeedTagProps) {
const tag = (
<Tag color="blue">
<Tag
color="blue"
className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
style={tableCell ? SPEED_TAG_STYLE : undefined}
>
{SizeFormatter.speedFormat(speed.up)}
{' / '}
{SizeFormatter.speedFormat(speed.down)}
@@ -10,6 +10,7 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { coerceInboundJsonField } from '@/models/dbinbound';
import { RowActionsCell } from './RowActions';
import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import {
readStreamHints,
@@ -324,14 +325,14 @@ export function useInboundColumns({
title: t('pages.inbounds.speed'),
key: 'speed',
align: 'center',
width: 110,
width: SPEED_COLUMN_WIDTH,
sorter: (a, b) => speedTotal(a) - speedTotal(b),
render: (_, record) => {
const speed = inboundSpeed[record.id];
if (!isActiveSpeed(speed)) {
return <Tag color='default'></Tag>;
return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}></Tag>;
}
return <InboundSpeedTag speed={speed} withTooltip />;
return <InboundSpeedTag speed={speed} withTooltip tableCell />;
},
},
{
+11 -2
View File
@@ -82,6 +82,16 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpFrom')} description={t('pages.settings.smtpFromDesc')}>
<Input value={allSetting.smtpFrom} placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpFrom: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpFromName')} description={t('pages.settings.smtpFromNameDesc')}>
<Input value={allSetting.smtpFromName} placeholder="3x-ui"
onChange={(e) => updateSetting({ smtpFromName: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
<Input value={allSetting.smtpTo} placeholder="admin@example.com, ops@example.com"
onChange={(e) => updateSetting({ smtpTo: e.target.value })} />
@@ -113,8 +123,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
: <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>
}
showIcon
closable
onClose={() => setTestResult(null)}
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
@@ -1,3 +1,18 @@
.subscription-format-sections {
display: grid;
gap: 16px;
}
.subscription-format-card-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.subscription-format-card .ant-card-body {
padding: 0;
}
.format-settings {
margin-bottom: 8px;
border: 1px solid var(--ant-color-border-secondary);
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Card,
Input,
InputNumber,
Select,
@@ -8,6 +9,8 @@ import {
Tabs,
} from 'antd';
import {
FileTextOutlined,
NodeIndexOutlined,
PartitionOutlined,
RocketOutlined,
SendOutlined,
@@ -15,6 +18,7 @@ import {
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { GoRegexInput } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
@@ -148,9 +152,18 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<div className="subscription-format-sections">
{allSetting.subJsonEnable && (
<>
<Card
size="small"
className="subscription-format-card"
title={(
<span className="subscription-format-card-title">
<FileTextOutlined />
{t('pages.settings.subJsonEnableTitle')}
</span>
)}
>
<SettingListItem paddings="small" title={<>JSON {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subJsonPath}
@@ -166,10 +179,40 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
/>
</SettingListItem>
</>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonAlwaysArray')}
description={t('pages.settings.subJsonAlwaysArrayDesc')}
>
<Switch checked={allSetting.subJsonAlwaysArray} onChange={(value) => updateSetting({ subJsonAlwaysArray: value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subJsonAutoDetect')} description={t('pages.settings.subJsonAutoDetectDesc')}>
<Switch checked={allSetting.subJsonAutoDetect} onChange={(v) => updateSetting({ subJsonAutoDetect: v })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonUserAgentRegex')}
description={t('pages.settings.subJsonUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subJsonUserAgentRegex}
placeholder="(?i)^myclient([ /]|$)"
onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
{allSetting.subClashEnable && (
<>
<Card
size="small"
className="subscription-format-card"
title={(
<span className="subscription-format-card-title">
<NodeIndexOutlined />
{t('pages.settings.subClashEnableTitle')}
</span>
)}
>
<SettingListItem paddings="small" title={<>Clash {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subClashPath}
@@ -185,9 +228,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
onChange={(e) => updateSetting({ subClashURI: e.target.value })}
/>
</SettingListItem>
</>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashAutoDetect')}
description={t('pages.settings.subClashAutoDetectDesc')}
>
<Switch
checked={allSetting.subClashAutoDetect}
onChange={(v) => updateSetting({ subClashAutoDetect: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashUserAgentRegex')}
description={t('pages.settings.subClashUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subClashUserAgentRegex}
placeholder="(?i)(clash|mihomo)"
onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
</>
</div>
),
},
{
@@ -1,6 +1,7 @@
import { Input, InputNumber, Switch, Tabs } from 'antd';
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form';
@@ -15,6 +16,7 @@ interface SubscriptionGeneralTabProps {
export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { isMobile } = useMediaQuery();
return (
@@ -33,6 +35,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
<Switch checked={allSetting.subClashEnable} onChange={(v) => updateSetting({ subClashEnable: v })} />
</SettingListItem>
{(allSetting.subJsonEnable || allSetting.subClashEnable) && (
<Alert
type="info"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subFormatsTipTitle')}
description={t('pages.settings.subFormatsTipDesc')}
action={(
<Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
{t('pages.settings.subFormatsTipAction')}
</Button>
)}
/>
)}
<SettingListItem paddings="small" title={t('pages.settings.subListen')} description={t('pages.settings.subListenDesc')}>
<Input value={allSetting.subListen} onChange={(e) => updateSetting({ subListen: e.target.value })} />
</SettingListItem>
+1 -2
View File
@@ -233,8 +233,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
type={testResult.success ? 'success' : 'error'}
title={testResult.msg}
showIcon
closable
onClose={() => setTestResult(null)}
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
+1 -1
View File
@@ -286,7 +286,7 @@ export default function SubPage() {
<Col xs={24} sm={22} md={18} lg={14} xl={12}>
<Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
{announce && (
<Alert type="info" showIcon message={announce} style={{ marginBottom: 16 }} />
<Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} />
)}
<Descriptions
bordered
+1 -1
View File
@@ -78,7 +78,7 @@ export default function SubUsageSummary({
percent={pct}
showInfo={false}
strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
trailColor="var(--ant-color-fill-secondary)"
railColor="var(--ant-color-fill-secondary)"
strokeWidth={10}
className="usage-summary-bar"
/>
@@ -257,7 +257,7 @@ export default function BalancerFormModal({
<Alert
type="info"
showIcon
message={t('pages.xray.balancer.balancerFallbackInfo')}
title={t('pages.xray.balancer.balancerFallbackInfo')}
style={{ marginBottom: 16 }}
/>
)}
@@ -265,7 +265,7 @@ export default function BalancerFormModal({
<Alert
type="error"
showIcon
message={t('pages.xray.balancer.balancerFallbackCycle')}
title={t('pages.xray.balancer.balancerFallbackCycle')}
style={{ marginBottom: 16 }}
/>
)}
@@ -86,7 +86,7 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
type="warning"
showIcon
className="preset-warning"
message={t('pages.xray.dns.dnsLeakWarning')}
title={t('pages.xray.dns.dnsLeakWarning')}
/>
<div className="preset-list">
{PRESETS.map((preset) => (
@@ -10,6 +10,7 @@ import {
DnsQueryStrategySchema,
DnsServerObjectInnerSchema,
DnsServerObjectSchema,
isEncryptedDnsAddress,
type DnsServerObject,
} from '@/schemas/dns';
@@ -109,7 +110,6 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
const out: Record<string, unknown> = {
address: values.address,
port: values.port,
domains: values.domains.filter(Boolean),
expectedIPs: values.expectedIPs.filter(Boolean),
unexpectedIPs: values.unexpectedIPs.filter(Boolean),
@@ -121,6 +121,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
serveExpiredTTL: values.serveExpiredTTL,
timeoutMs: values.timeoutMs,
};
if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
if (values.tag) out.tag = values.tag;
if (values.clientIP) out.clientIP = values.clientIP;
return out as DnsServerValue;
+1 -1
View File
@@ -240,7 +240,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
<Alert
type="warning"
showIcon
message={t('pages.xray.dns.dnsLeakWarning')}
title={t('pages.xray.dns.dnsLeakWarning')}
style={{ marginBottom: 12 }}
/>
<SettingListItem
+11 -2
View File
@@ -14,9 +14,13 @@ const DnsHostValueSchema = z.union([z.string(), z.array(z.string())]);
export const DnsHostsSchema = z.record(z.string(), DnsHostValueSchema);
export type DnsHosts = z.infer<typeof DnsHostsSchema>;
export function isEncryptedDnsAddress(address: string): boolean {
return /^(https|https\+local|h2c|h2c\+local|quic\+local):\/\//i.test(address);
}
export const DnsServerObjectInnerSchema = z.object({
address: z.string(),
port: PortSchema.default(53),
port: PortSchema.optional(),
domains: z.array(z.string()).optional(),
expectedIPs: z.array(z.string()).optional(),
unexpectedIPs: z.array(z.string()).optional(),
@@ -41,7 +45,12 @@ export const DnsServerObjectSchema = z.preprocess(
return val;
},
DnsServerObjectInnerSchema,
);
).transform((v) => {
if (v.port === undefined && !isEncryptedDnsAddress(v.address)) {
return { ...v, port: 53 };
}
return v;
});
export type DnsServerObject = z.infer<typeof DnsServerObjectSchema>;
export const DnsServerEntrySchema = z.union([z.string(), DnsServerObjectSchema]);
+5
View File
@@ -33,6 +33,11 @@ export const AllSettingSchema = z.object({
xrayTemplateConfig: z.string().optional(),
subEnable: z.boolean().optional(),
subJsonEnable: z.boolean().optional(),
subJsonAutoDetect: z.boolean().optional(),
subJsonAlwaysArray: z.boolean().optional(),
subJsonUserAgentRegex: z.string().max(2048).optional(),
subClashAutoDetect: z.boolean().optional(),
subClashUserAgentRegex: z.string().max(2048).optional(),
subTitle: z.string().optional(),
subSupportUrl: z.string().optional(),
subProfileUrl: z.string().optional(),
@@ -41,7 +41,6 @@ exports[`DnsObjectSchema fixtures > parses full byte-stably 1`] = `
"address": "quic+local://dns.adguard.com",
"disableCache": true,
"finalQuery": true,
"port": 53,
"serveExpiredTTL": 60,
"serveStale": false,
"timeoutMs": 5000,
@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import ClientCardComment from '@/components/clients/ClientCardComment';
describe('ClientCardComment', () => {
it('renders a client comment in the card', () => {
render(<ClientCardComment comment={'Primary mobile client\nLine two'} />);
const comment = screen.getByText(/Primary mobile client/);
expect(comment.className).toContain('client-card-comment');
expect(comment.textContent).toBe('Primary mobile client\nLine two');
expect(comment.getAttribute('title')).toBe('Primary mobile client\nLine two');
});
it('supports the compact desktop style', () => {
render(<ClientCardComment comment="Desktop comment" className="sub" />);
expect(screen.getByText('Desktop comment').className).toBe('sub');
});
it('renders nothing when no comment is set', () => {
const { container } = render(<ClientCardComment comment="" />);
expect(container.childElementCount).toBe(0);
});
});
+42
View File
@@ -41,3 +41,45 @@ describe('DnsServerObjectSchema fixtures', () => {
});
}
});
describe('DnsServerObjectSchema port defaulting', () => {
it('defaults port 53 for a plain address', () => {
const parsed = DnsServerObjectSchema.parse({ address: '8.8.8.8' });
expect(parsed.port).toBe(53);
});
it('defaults port 53 for a tcp address', () => {
const parsed = DnsServerObjectSchema.parse({ address: 'tcp://1.1.1.1' });
expect(parsed.port).toBe(53);
});
it('omits port for a DoH (https://) address', () => {
const parsed = DnsServerObjectSchema.parse({ address: 'https://cloudflare-dns.com/dns-query' });
expect(parsed.port).toBeUndefined();
});
it('omits port for a DoHL (https+local://) address', () => {
const parsed = DnsServerObjectSchema.parse({ address: 'https+local://dns.google/dns-query' });
expect(parsed.port).toBeUndefined();
});
it('omits port for a DoQ (quic+local://) address', () => {
const parsed = DnsServerObjectSchema.parse({ address: 'quic+local://dns.adguard.com' });
expect(parsed.port).toBeUndefined();
});
it('omits port for an h2c and h2c+local address', () => {
expect(DnsServerObjectSchema.parse({ address: 'h2c://dns.example.com/dns-query' }).port).toBeUndefined();
expect(DnsServerObjectSchema.parse({ address: 'h2c+local://dns.example.com/dns-query' }).port).toBeUndefined();
});
it('omits port for an uppercase encrypted scheme', () => {
const parsed = DnsServerObjectSchema.parse({ address: 'HTTPS://dns.google/dns-query' });
expect(parsed.port).toBeUndefined();
});
it('preserves an explicit port on an encrypted address', () => {
const parsed = DnsServerObjectSchema.parse({ address: 'https://dns.google/dns-query', port: 8443 });
expect(parsed.port).toBe(8443);
});
});
@@ -0,0 +1,84 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ClientSpeedTag } from '@/components/clients/ClientSpeedTag';
import {
SPEED_COLUMN_WIDTH,
SPEED_TABLE_CELL_INLINE_PADDING,
SPEED_TAG_CLASS_NAME,
SPEED_TAG_STYLE,
SPEED_TAG_WIDTH,
} from '@/components/utility/speedTagStyle';
import { InboundSpeedTag } from '@/pages/inbounds/list/InboundSpeedTag';
import { SizeFormatter } from '@/utils';
import '@/styles/utils.css';
const SMALL_RATE = { up: 512, down: 1023 } as const;
const LARGE_RATE = { up: 12_340_000, down: 99_990_000 } as const;
const FORMATTER_BOUNDARY_RATE = {
up: SizeFormatter.ONE_GB - 1,
down: SizeFormatter.ONE_GB - 1,
} as const;
const FORMATTER_BOUNDARY_NATURAL_WIDTH = 192.765625;
function firstTag(): HTMLElement {
const tag = document.querySelector('.ant-tag');
if (!(tag instanceof HTMLElement)) {
throw new Error('expected an Ant Design Tag in the document');
}
return tag;
}
function expectFluidTag(tag: HTMLElement) {
expect(tag.classList.contains(SPEED_TAG_CLASS_NAME)).toBe(false);
expect(tag.style.width).toBe('');
}
function expectStableTableTag(tag: HTMLElement) {
const style = getComputedStyle(tag);
expect(tag.classList.contains(SPEED_TAG_CLASS_NAME)).toBe(true);
expect(style.width).toBe(`${SPEED_TAG_WIDTH}px`);
expect(style.display).toBe(SPEED_TAG_STYLE.display);
expect(style.justifyContent).toBe(SPEED_TAG_STYLE.justifyContent);
expect(style.alignItems).toBe(SPEED_TAG_STYLE.alignItems);
expect(style.textAlign).toBe(SPEED_TAG_STYLE.textAlign);
expect(style.whiteSpace).toBe(SPEED_TAG_STYLE.whiteSpace);
expect(style.fontVariantNumeric).toBe(SPEED_TAG_STYLE.fontVariantNumeric);
expect(style.overflow).toBe('hidden');
expect(style.textOverflow).toBe('ellipsis');
}
describe('stable table speed tags (issue #5912)', () => {
it('scopes ClientSpeedTag stable sizing to table cells', () => {
const { rerender } = render(<ClientSpeedTag speed={SMALL_RATE} />);
expectFluidTag(firstTag());
rerender(<ClientSpeedTag speed={LARGE_RATE} tableCell />);
expectStableTableTag(firstTag());
});
it('scopes InboundSpeedTag stable sizing to table cells', () => {
const { rerender } = render(<InboundSpeedTag speed={SMALL_RATE} />);
expectFluidTag(firstTag());
rerender(<InboundSpeedTag speed={LARGE_RATE} withTooltip tableCell />);
expectStableTableTag(firstTag());
});
it('fits the widest formatter rollover and includes small-cell padding', () => {
render(
<>
<ClientSpeedTag speed={FORMATTER_BOUNDARY_RATE} tableCell />
<InboundSpeedTag speed={FORMATTER_BOUNDARY_RATE} tableCell />
</>,
);
const tags = Array.from(document.querySelectorAll<HTMLElement>('.ant-tag'));
expect(tags).toHaveLength(2);
expect(tags[0]?.textContent).toBe('↑ 1024.00 MB/s / ↓ 1024.00 MB/s');
expect(tags[1]?.textContent).toBe(tags[0]?.textContent);
for (const tag of tags) expectStableTableTag(tag);
expect(SPEED_TAG_WIDTH).toBeGreaterThan(FORMATTER_BOUNDARY_NATURAL_WIDTH);
expect(SPEED_COLUMN_WIDTH).toBe(SPEED_TAG_WIDTH + SPEED_TABLE_CELL_INLINE_PADDING * 2);
});
});
@@ -0,0 +1,29 @@
import { fireEvent, screen } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { AllSetting } from '@/models/setting';
import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
import { renderWithProviders } from './test-utils';
function LocationProbe() {
const location = useLocation();
return <output data-testid="location">{location.pathname}{location.hash}</output>;
}
describe('SubscriptionGeneralTab', () => {
it('uses router navigation to open subscription format settings', () => {
const allSetting = new AllSetting({ subClashEnable: true });
renderWithProviders(
<MemoryRouter initialEntries={['/settings#subscription']}>
<SubscriptionGeneralTab allSetting={allSetting} updateSetting={vi.fn()} />
<LocationProbe />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open Sub Formats' }));
expect(screen.getByTestId('location').textContent).toBe('/settings#subscription-formats');
});
});
+22 -1
View File
@@ -1,13 +1,18 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@': path.resolve(dirname, 'src'),
},
},
test: {
@@ -31,6 +36,22 @@ export default defineConfig({
setupFiles: ['./src/test/setup.ts', './src/test/setup.components.ts'],
},
},
{
extends: true,
optimizeDeps: {
include: ['aria-query', 'lz-string', 'pretty-format', 'dom-accessibility-api'],
},
plugins: [storybookTest({ configDir: path.join(dirname, '.storybook') })],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{ browser: 'chromium' }],
},
},
},
],
},
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="@vitest/browser-playwright" />
+2 -2
View File
@@ -1424,7 +1424,7 @@ install_x-ui() {
exit 1
fi
echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --speed-limit 1 --speed-time 300 -o ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
if [[ $? -ne 0 ]]; then
echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
exit 1
@@ -1454,7 +1454,7 @@ install_x-ui() {
url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
echo -e "Beginning to install x-ui ${tag_version}"
curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --speed-limit 1 --speed-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
if [[ $? -ne 0 ]]; then
echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}"
exit 1
+271 -53
View File
@@ -10,10 +10,12 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/gin-gonic/gin"
@@ -54,13 +56,18 @@ type SUBController struct {
subIncyEnableRouting bool
subIncyRoutingRules string
subPath string
subJsonPath string
subClashPath string
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subPath string
subJsonPath string
subClashPath string
subClashAutoDetect bool
clashUserAgent *regexp.Regexp
jsonAutoDetect bool
jsonUserAgent *regexp.Regexp
jsonAlwaysArray bool
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subService *SubService
subJsonService *SubJsonService
@@ -71,56 +78,198 @@ type SUBController struct {
subTemplateCache map[string]*cachedSubTemplate
}
type subControllerConfig struct {
subPath string
subJsonPath string
subClashPath string
subClashAutoDetect bool
subClashUserAgentRegex string
subJsonAutoDetect bool
subJsonUserAgentRegex string
subJsonAlwaysArray bool
subJsonEnabled bool
subClashEnabled bool
subEncrypt bool
remarkTemplate string
updateInterval string
subJsonMux string
subJsonRules string
subJsonFinalMask string
subClashEnableRouting bool
subClashRules string
subTitle string
subSupportURL string
subProfileURL string
subAnnounce string
subEnableRouting bool
subRoutingRules string
subHideSettings bool
subIncyEnableRouting bool
subIncyRoutingRules string
}
type SUBControllerOption func(*subControllerConfig)
func WithSUBPath(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subPath = value }
}
func WithSUBJsonPath(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonPath = value }
}
func WithSUBClashPath(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashPath = value }
}
func WithSUBClashAutoDetect(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashAutoDetect = value }
}
func WithSUBClashUserAgentRegex(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashUserAgentRegex = value }
}
func WithSUBJsonAutoDetect(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonAutoDetect = value }
}
func WithSUBJsonUserAgentRegex(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonUserAgentRegex = value }
}
func WithSUBJsonAlwaysArray(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonAlwaysArray = value }
}
func WithSUBJsonEnabled(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonEnabled = value }
}
func WithSUBClashEnabled(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashEnabled = value }
}
func WithSUBEncryption(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subEncrypt = value }
}
func WithSUBRemarkTemplate(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.remarkTemplate = value }
}
func WithSUBUpdateInterval(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.updateInterval = value }
}
func WithSUBJsonMux(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonMux = value }
}
func WithSUBJsonRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonRules = value }
}
func WithSUBJsonFinalMask(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
}
func WithSUBClashEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashEnableRouting = value }
}
func WithSUBClashRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashRules = value }
}
func WithSUBTitle(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subTitle = value }
}
func WithSUBSupportURL(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subSupportURL = value }
}
func WithSUBProfileURL(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subProfileURL = value }
}
func WithSUBAnnounce(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subAnnounce = value }
}
func WithSUBEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subEnableRouting = value }
}
func WithSUBRoutingRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subRoutingRules = value }
}
func WithSUBHideSettings(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subHideSettings = value }
}
func WithSUBIncyEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subIncyEnableRouting = value }
}
func WithSUBIncyRoutingRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
}
func defaultSUBControllerConfig() subControllerConfig {
return subControllerConfig{
subPath: "/sub/",
subJsonPath: "/json/",
subClashPath: "/clash/",
subEncrypt: true,
remarkTemplate: service.DefaultRemarkTemplate,
updateInterval: "12",
}
}
// NewSUBController creates a new subscription controller with the given configuration.
func NewSUBController(
g *gin.RouterGroup,
subPath string,
jsonPath string,
clashPath string,
jsonEnabled bool,
clashEnabled bool,
encrypt bool,
remarkTemplate string,
update string,
jsonMux string,
jsonRules string,
jsonFinalMask string,
clashEnableRouting bool,
clashRules string,
subTitle string,
subSupportUrl string,
subProfileUrl string,
subAnnounce string,
subEnableRouting bool,
subRoutingRules string,
subHideSettings bool,
subIncyEnableRouting bool,
subIncyRoutingRules string,
) *SUBController {
sub := NewSubService(remarkTemplate)
func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBController {
config := defaultSUBControllerConfig()
for _, option := range options {
option(&config)
}
sub := NewSubService(config.remarkTemplate)
a := &SUBController{
subTitle: subTitle,
subSupportUrl: subSupportUrl,
subProfileUrl: subProfileUrl,
subAnnounce: subAnnounce,
subEnableRouting: subEnableRouting,
subRoutingRules: subRoutingRules,
subHideSettings: subHideSettings,
subTitle: config.subTitle,
subSupportUrl: config.subSupportURL,
subProfileUrl: config.subProfileURL,
subAnnounce: config.subAnnounce,
subEnableRouting: config.subEnableRouting,
subRoutingRules: config.subRoutingRules,
subHideSettings: config.subHideSettings,
subIncyEnableRouting: subIncyEnableRouting,
subIncyRoutingRules: subIncyRoutingRules,
subIncyEnableRouting: config.subIncyEnableRouting,
subIncyRoutingRules: config.subIncyRoutingRules,
subPath: subPath,
subJsonPath: jsonPath,
subClashPath: clashPath,
jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled,
subEncrypt: encrypt,
updateInterval: update,
subPath: config.subPath,
subJsonPath: config.subJsonPath,
subClashPath: config.subClashPath,
subClashAutoDetect: config.subClashAutoDetect,
clashUserAgent: compileUserAgentRegex("Clash/Mihomo", config.subClashUserAgentRegex, service.DefaultSubClashUserAgentRegex),
jsonAutoDetect: config.subJsonAutoDetect,
jsonUserAgent: compileUserAgentRegex("Xray JSON", config.subJsonUserAgentRegex, service.DefaultSubJsonUserAgentRegex),
jsonAlwaysArray: config.subJsonAlwaysArray,
jsonEnabled: config.subJsonEnabled,
clashEnabled: config.subClashEnabled,
subEncrypt: config.subEncrypt,
updateInterval: config.updateInterval,
subService: sub,
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
subJsonService: NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub),
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
subTemplateCache: map[string]*cachedSubTemplate{},
}
@@ -186,9 +335,22 @@ func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
func (a *SUBController) subs(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
if a.maybeServeSubPage(c) {
logSubscriptionRoute(userAgent, "html")
return
}
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) {
logSubscriptionRoute(userAgent, "clash")
a.subClashs(c)
return
}
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) {
logSubscriptionRoute(userAgent, "json")
a.serveJson(c, true, "application/json; charset=utf-8")
return
}
logSubscriptionRoute(userAgent, "raw")
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
subReq := a.subService.ForRequest(host)
@@ -224,6 +386,58 @@ func (a *SUBController) subs(c *gin.Context) {
}
}
func shouldAutoServeClash(autoDetect, clashEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
return shouldAutoServeFormat(autoDetect, clashEnabled, wantsHTML, userAgent, userAgentRegex)
}
func shouldAutoServeJson(autoDetect, jsonEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
return shouldAutoServeFormat(autoDetect, jsonEnabled, wantsHTML, userAgent, userAgentRegex)
}
func shouldAutoServeFormat(autoDetect, formatEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
if !autoDetect || !formatEnabled || wantsHTML || userAgentRegex == nil {
return false
}
return userAgentRegex.MatchString(userAgent)
}
func logSubscriptionRoute(userAgent, branch string) {
logger.Debugf("Subscription request routed: branch=%s user_agent=%q", branch, sanitizeUserAgentForLog(userAgent))
}
func sanitizeUserAgentForLog(userAgent string) string {
clean := strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return ' '
}
return r
}, userAgent)
runes := []rune(clean)
if len(runes) > 512 {
return string(runes[:512])
}
return clean
}
func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
pattern = strings.TrimSpace(defaultPattern)
}
if pattern == "" {
return nil
}
compiled, err := regexp.Compile(pattern)
if err == nil {
return compiled
}
logger.Warningf("Invalid %s User-Agent regex %q; falling back to default %q: %v", name, pattern, defaultPattern, err)
if strings.TrimSpace(defaultPattern) == "" {
return nil
}
return regexp.MustCompile(defaultPattern)
}
// serveSubPage renders internal/web/dist/subpage.html for the current subscription
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
// we inject that here, along with window.X_UI_BASE_PATH so the
@@ -387,9 +601,13 @@ func (a *SUBController) subJsons(c *gin.Context) {
if a.maybeServeSubPage(c) {
return
}
a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
}
func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
if err != nil || len(jsonSub) == 0 {
writeSubError(c, err)
} else {
@@ -399,7 +617,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
}
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
c.String(200, jsonSub)
c.Data(200, contentType, []byte(jsonSub))
}
}
+329
View File
@@ -2,10 +2,18 @@ package sub
import (
"bytes"
"encoding/base64"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// newTestSUBController builds a controller with just the bits loadSubTemplate
@@ -14,6 +22,327 @@ func newTestSUBController() *SUBController {
return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
}
type subscriptionTestRouterConfig struct {
clashAutoDetect bool
clashUserAgentRegex string
jsonAutoDetect bool
jsonUserAgentRegex string
jsonAlwaysArray bool
}
func newSubscriptionTestRouter(config subscriptionTestRouterConfig) *gin.Engine {
router := gin.New()
options := []SUBControllerOption{
WithSUBJsonEnabled(true),
WithSUBClashEnabled(true),
}
if config.clashAutoDetect {
options = append(options, WithSUBClashAutoDetect(true))
}
if config.clashUserAgentRegex != "" {
options = append(options, WithSUBClashUserAgentRegex(config.clashUserAgentRegex))
}
if config.jsonAutoDetect {
options = append(options, WithSUBJsonAutoDetect(true))
}
if config.jsonUserAgentRegex != "" {
options = append(options, WithSUBJsonUserAgentRegex(config.jsonUserAgentRegex))
}
if config.jsonAlwaysArray {
options = append(options, WithSUBJsonAlwaysArray(true))
}
NewSUBController(router.Group("/"), options...)
return router
}
func TestNewSUBControllerOptions(t *testing.T) {
gin.SetMode(gin.TestMode)
defaults := NewSUBController(gin.New().Group("/"))
if defaults.subPath != "/sub/" || defaults.subJsonPath != "/json/" || defaults.subClashPath != "/clash/" {
t.Fatalf("default paths = %q, %q, %q", defaults.subPath, defaults.subJsonPath, defaults.subClashPath)
}
if !defaults.subEncrypt || defaults.updateInterval != "12" {
t.Fatalf("default encryption/update = %v, %q", defaults.subEncrypt, defaults.updateInterval)
}
if defaults.subService.remarkTemplate != service.DefaultRemarkTemplate {
t.Fatalf("default remark template = %q", defaults.subService.remarkTemplate)
}
if defaults.jsonEnabled || defaults.clashEnabled {
t.Fatalf("format endpoints enabled by default: json=%v clash=%v", defaults.jsonEnabled, defaults.clashEnabled)
}
configured := NewSUBController(
gin.New().Group("/"),
WithSUBPath("/custom/"),
WithSUBJsonEnabled(true),
WithSUBEncryption(false),
WithSUBUpdateInterval("24"),
)
if configured.subPath != "/custom/" || !configured.jsonEnabled || configured.subEncrypt || configured.updateInterval != "24" {
t.Fatalf("configured values were not applied: path=%q json=%v encrypt=%v update=%q",
configured.subPath, configured.jsonEnabled, configured.subEncrypt, configured.updateInterval)
}
}
func TestShouldAutoServeClash(t *testing.T) {
tests := []struct {
name string
autoDetect bool
clashEnabled bool
wantsHTML bool
userAgent string
pattern string
want bool
}{
{name: "clash verge", autoDetect: true, clashEnabled: true, userAgent: "Clash-Verge/v2.4.2", want: true},
{name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
{name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
{name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
{name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
{name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
{name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
{name: "empty raw fallback", autoDetect: true, clashEnabled: true},
{name: "browser HTML wins", autoDetect: true, clashEnabled: true, wantsHTML: true, userAgent: "Clash-Verge/v2.4.2"},
{name: "disabled by default", clashEnabled: true, userAgent: "mihomo/1.19.12"},
{name: "clash endpoint disabled", autoDetect: true, userAgent: "mihomo/1.19.12"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldAutoServeClash(tt.autoDetect, tt.clashEnabled, tt.wantsHTML, tt.userAgent, compileUserAgentRegex("Clash/Mihomo", tt.pattern, service.DefaultSubClashUserAgentRegex))
if got != tt.want {
t.Fatalf("shouldAutoServeClash() = %v, want %v", got, tt.want)
}
})
}
}
func TestShouldAutoServeClashUsesConfiguredRegex(t *testing.T) {
configured := compileUserAgentRegex("Clash/Mihomo", `(?i)^custom-client/`, service.DefaultSubClashUserAgentRegex)
if !shouldAutoServeClash(true, true, false, "Custom-Client/1.0", configured) {
t.Fatal("configured User-Agent regex did not match")
}
if shouldAutoServeClash(true, true, false, "Mihomo/1.19", configured) {
t.Fatal("built-in User-Agent matched after a custom regex replaced it")
}
}
func TestShouldAutoServeJson(t *testing.T) {
configured := compileUserAgentRegex("Xray JSON", `(?i)^jsonclient([ /]|$)`, service.DefaultSubJsonUserAgentRegex)
for _, userAgent := range []string{"JsonClient/1.6.32", "jsonclient 1.6.32"} {
if !shouldAutoServeJson(true, true, false, userAgent, configured) {
t.Errorf("configured Xray JSON regex did not match %q", userAgent)
}
}
for _, userAgent := range []string{"GenericClient/1.10.0", "OtherClient/2.2", "ThirdClient/7.0", "CustomClient/1.0"} {
if shouldAutoServeJson(true, true, false, userAgent, configured) {
t.Errorf("configured Xray JSON regex unexpectedly matched %q", userAgent)
}
}
if shouldAutoServeJson(false, true, false, "JsonClient/1.6.32", configured) {
t.Fatal("disabled Xray JSON auto-detection matched")
}
if shouldAutoServeJson(true, false, false, "JsonClient/1.6.32", configured) {
t.Fatal("disabled JSON endpoint matched")
}
if shouldAutoServeJson(true, true, true, "JsonClient/1.6.32", configured) {
t.Fatal("browser HTML request matched Xray JSON")
}
empty := compileUserAgentRegex("Xray JSON", "", service.DefaultSubJsonUserAgentRegex)
if empty != nil {
t.Fatal("empty Xray JSON default should not compile to a matcher")
}
if shouldAutoServeJson(true, true, false, "JsonClient/1.6.32", empty) {
t.Fatal("empty Xray JSON default should not auto-serve")
}
}
func TestShouldAutoServeJsonUsesConfiguredRegex(t *testing.T) {
configured := compileUserAgentRegex("Xray JSON", `(?i)^custom-json/`, service.DefaultSubJsonUserAgentRegex)
if !shouldAutoServeJson(true, true, false, "Custom-JSON/1.0", configured) {
t.Fatal("configured Xray JSON User-Agent regex did not match")
}
if shouldAutoServeJson(true, true, false, "OtherClient/1.10.0", configured) {
t.Fatal("unrelated User-Agent matched after a custom regex was configured")
}
}
func TestCompileUserAgentRegexFallsBackForInvalidPattern(t *testing.T) {
compiled := compileUserAgentRegex("Clash/Mihomo", "[", service.DefaultSubClashUserAgentRegex)
if !compiled.MatchString("Mihomo/1.19") {
t.Fatal("invalid regex did not fall back to the default pattern")
}
}
func TestSanitizeUserAgentForLog(t *testing.T) {
if got := sanitizeUserAgentForLog("client/1.0\r\nforged\tline"); got != "client/1.0 forged line" {
t.Fatalf("sanitizeUserAgentForLog() = %q", got)
}
long := strings.Repeat("界", 513)
if got := sanitizeUserAgentForLog(long); len([]rune(got)) != 512 {
t.Fatalf("sanitized User-Agent length = %d runes, want 512", len([]rune(got)))
}
}
func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
t.Run("recognized client receives YAML", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, want YAML", got)
}
if body := resp.Body.String(); !strings.Contains(body, "proxies:") || !strings.Contains(body, "type: vless") {
t.Fatalf("auto-detected body is not Clash YAML:\n%s", body)
}
})
t.Run("Clash wins when both format regexes match", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Hybrid/1.0")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
clashUserAgentRegex: `(?i)^hybrid/`,
jsonAutoDetect: true,
jsonUserAgentRegex: `(?i)^hybrid/`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, want Clash YAML", got)
}
})
t.Run("disabled setting preserves raw base64", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("raw response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "vless://") {
t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
}
})
t.Run("configured regex controls detection", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Mihomo/1.19")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
clashUserAgentRegex: `(?i)^custom-client/`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got == "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, custom regex should preserve raw response", got)
}
})
t.Run("unrecognized client preserves raw base64", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "GenericClient/1.10.0")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
jsonAutoDetect: true,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("raw response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "vless://") {
t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
}
})
t.Run("recognized Xray JSON client receives configuration array", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "JsonClient/1.6.32")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
jsonAutoDetect: true,
jsonUserAgentRegex: `(?i)^jsonclient([ /]|$)`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("Content-Type = %q, want JSON", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") || !strings.Contains(body, `"outbounds"`) {
t.Fatalf("auto-detected body is not an Xray JSON configuration array:\n%s", body)
}
})
t.Run("explicit JSON endpoint preserves legacy single object by default", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q, want legacy text/plain", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "{") {
t.Fatalf("legacy explicit JSON body is not an object: %s", body)
}
})
t.Run("explicit JSON endpoint can follow XTLS array standard", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAlwaysArray: true}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q, want legacy text/plain", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") {
t.Fatalf("standards-compliant explicit JSON body is not an array: %s", body)
}
})
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+19 -1
View File
@@ -1,6 +1,7 @@
package sub
import (
"encoding/json"
"strings"
"testing"
@@ -27,7 +28,8 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
base := NewSubService("")
jsonOut, _, err := NewSubJsonService("", "", "", base).GetJson("ext-only", "sub.example.com")
jsonService := NewSubJsonService("", "", "", base)
jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
if err != nil {
t.Fatalf("GetJson err = %v", err)
}
@@ -37,6 +39,22 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
if !strings.Contains(jsonOut, "DE-Provider") {
t.Fatalf("GetJson missing external remark: %s", jsonOut)
}
var config map[string]any
if err := json.Unmarshal([]byte(jsonOut), &config); err != nil {
t.Fatalf("legacy GetJson must return an object for a single profile: %v; body=%s", err, jsonOut)
}
standardOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", true)
if err != nil {
t.Fatalf("standards-compliant GetJson err = %v", err)
}
var configs []map[string]any
if err := json.Unmarshal([]byte(standardOut), &configs); err != nil {
t.Fatalf("standards-compliant GetJson must return an array for a single profile: %v; body=%s", err, standardOut)
}
if len(configs) != 1 {
t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs))
}
clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
if err != nil {
+2 -2
View File
@@ -304,7 +304,7 @@ func TestSub_HostSockoptJSON(t *testing.T) {
SockoptParams: `{"tcpFastOpen":true}`,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com")
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
@@ -322,7 +322,7 @@ func TestSub_HostMuxJSON(t *testing.T) {
MuxParams: `{"enabled":true,"concurrency":8}`,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com")
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
+2 -3
View File
@@ -58,7 +58,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
}
// GetJson generates a JSON subscription configuration for the given subscription ID and host.
func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
subReq := s.SubService.ForRequest(host)
subReq.subscriptionBody = true
inbounds, err := subReq.getInboundsBySubId(subId)
@@ -125,9 +125,8 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
}
traffic, _ := subReq.AggregateTrafficByEmails(emails)
// Combile outbounds
var finalJson []byte
if len(configArray) == 1 {
if len(configArray) == 1 && !alwaysReturnArray {
finalJson, _ = json.MarshalIndent(configArray[0], "", " ")
} else {
finalJson, _ = json.MarshalIndent(configArray, "", " ")
+54 -5
View File
@@ -89,6 +89,31 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return nil, err
}
subClashAutoDetect, err := s.settingService.GetSubClashAutoDetect()
if err != nil {
subClashAutoDetect = false
}
subJsonAutoDetect, err := s.settingService.GetSubJsonAutoDetect()
if err != nil {
subJsonAutoDetect = false
}
subJsonAlwaysArray, err := s.settingService.GetSubJsonAlwaysArray()
if err != nil {
subJsonAlwaysArray = false
}
subJsonUserAgentRegex, err := s.settingService.GetSubJsonUserAgentRegex()
if err != nil {
subJsonUserAgentRegex = service.DefaultSubJsonUserAgentRegex
}
subClashUserAgentRegex, err := s.settingService.GetSubClashUserAgentRegex()
if err != nil {
subClashUserAgentRegex = service.DefaultSubClashUserAgentRegex
}
// Set base_path based on LinksPath for template rendering
// Ensure LinksPath ends with "/" for proper asset URL generation
basePath := LinksPath
@@ -239,11 +264,35 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group("/")
s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings,
SubIncyEnableRouting, SubIncyRoutingRules)
s.sub = NewSUBController(g,
WithSUBPath(LinksPath),
WithSUBJsonPath(JsonPath),
WithSUBClashPath(ClashPath),
WithSUBClashAutoDetect(subClashAutoDetect),
WithSUBClashUserAgentRegex(subClashUserAgentRegex),
WithSUBJsonAutoDetect(subJsonAutoDetect),
WithSUBJsonUserAgentRegex(subJsonUserAgentRegex),
WithSUBJsonAlwaysArray(subJsonAlwaysArray),
WithSUBJsonEnabled(subJsonEnable),
WithSUBClashEnabled(subClashEnable),
WithSUBEncryption(Encrypt),
WithSUBRemarkTemplate(RemarkTemplate),
WithSUBUpdateInterval(SubUpdates),
WithSUBJsonMux(SubJsonMux),
WithSUBJsonRules(SubJsonRules),
WithSUBJsonFinalMask(SubJsonFinalMask),
WithSUBClashEnableRouting(SubClashEnableRouting),
WithSUBClashRules(SubClashRules),
WithSUBTitle(SubTitle),
WithSUBSupportURL(SubSupportUrl),
WithSUBProfileURL(SubProfileUrl),
WithSUBAnnounce(SubAnnounce),
WithSUBEnableRouting(SubEnableRouting),
WithSUBRoutingRules(SubRoutingRules),
WithSUBHideSettings(SubHideSettings),
WithSUBIncyEnableRouting(SubIncyEnableRouting),
WithSUBIncyRoutingRules(SubIncyRoutingRules),
)
return engine, nil
}

Some files were not shown because too many files have changed in this diff Show More