mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-26 21:17:14 +00:00
Compare commits
3 Commits
380aff4d82
...
ce63bf3e66
| Author | SHA1 | Date | |
|---|---|---|---|
| ce63bf3e66 | |||
| b9eda09da9 | |||
| 92fb94d856 |
@@ -8,6 +8,7 @@ on:
|
||||
- "go.sum"
|
||||
- "frontend/**"
|
||||
- ".nvmrc"
|
||||
- "Makefile"
|
||||
- ".github/workflows/ci.yml"
|
||||
push:
|
||||
branches:
|
||||
@@ -18,6 +19,7 @@ on:
|
||||
- "go.sum"
|
||||
- "frontend/**"
|
||||
- ".nvmrc"
|
||||
- "Makefile"
|
||||
- ".github/workflows/ci.yml"
|
||||
|
||||
permissions:
|
||||
@@ -188,6 +190,9 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
working-directory: frontend
|
||||
|
||||
@@ -42,6 +42,9 @@ jobs:
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ file locations when it can answer in one hop.
|
||||
|
||||
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
|
||||
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
|
||||
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
|
||||
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Zod schemas in
|
||||
`src/schemas/` are the source of truth; infer types with `z.infer`, never
|
||||
hand-write. Do not edit `src/generated/`.
|
||||
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
|
||||
@@ -147,7 +147,8 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every
|
||||
`make` Go target already depends on it, which is why `make test-go` beats
|
||||
`go test ./...`. Run `make help` for all targets. The local gate:
|
||||
|
||||
make verify # gen-check + lint + typecheck + test + build + build-storybook
|
||||
make verify # gen-check + lint + format-check + typecheck + test + build
|
||||
# + build-storybook
|
||||
|
||||
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
|
||||
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a
|
||||
|
||||
+5
-4
@@ -186,7 +186,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
|
||||
- **Function components + hooks** everywhere. No class components.
|
||||
- **Comments in committed Go/TS/TSX: 2 lines MAX per comment block**, spent on the *why* a name cannot hold — an invariant, an issue number, a non-obvious constraint. Names should carry the meaning; rename rather than annotate. Compiler and tool directives (`//go:build`, `//go:generate`, `//nolint:`) are exempt, and HTML `<!-- ... -->` is fine for template structure.
|
||||
- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
|
||||
- **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
|
||||
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
|
||||
- **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.
|
||||
@@ -200,7 +200,8 @@ frontend/
|
||||
├── login.html — login + 2FA entry
|
||||
├── subpage.html — public subscription viewer entry
|
||||
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
|
||||
├── eslint.config.js — ESLint flat config (@eslint/js + typescript-eslint + react-hooks)
|
||||
├── .oxlintrc.json — oxlint config (typescript + react-hooks + jsx-a11y)
|
||||
├── tools/oxlint/ — input-number-guard.mjs (#6121/#6127 guard as a JS plugin)
|
||||
├── vite.config.js
|
||||
├── vitest.config.ts
|
||||
├── scripts/ — build-openapi.mjs (endpoints.ts → openapi.json)
|
||||
@@ -279,7 +280,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`/`build-storybook`. 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`/`format:check`/`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
|
||||
|
||||
@@ -288,7 +289,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 && npm run build-storybook` (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 format:check && 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.
|
||||
|
||||
|
||||
@@ -31,12 +31,16 @@ lint-go: dist-stub ## golangci-lint on Go sources
|
||||
golangci-lint run
|
||||
|
||||
.PHONY: lint-fe
|
||||
lint-fe: ## ESLint on frontend sources
|
||||
lint-fe: ## oxlint on frontend sources
|
||||
cd $(FRONTEND) && npm run lint
|
||||
|
||||
.PHONY: lint
|
||||
lint: lint-go lint-fe ## All linters
|
||||
|
||||
.PHONY: format-check
|
||||
format-check: ## oxfmt in check mode on frontend sources
|
||||
cd $(FRONTEND) && npm run format:check
|
||||
|
||||
.PHONY: typecheck
|
||||
typecheck: ## tsc --noEmit
|
||||
cd $(FRONTEND) && npm run typecheck
|
||||
@@ -76,8 +80,8 @@ build: build-fe ## Build the frontend then the Go binary
|
||||
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, a full build, and the Storybook compile-check.
|
||||
# The PR gate. Matches ci.yml: codegen freshness, both linters, the formatter,
|
||||
# typecheck, both test suites, a full build, and the Storybook compile-check.
|
||||
.PHONY: verify
|
||||
verify: gen-check lint typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI)
|
||||
verify: gen-check lint format-check typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI)
|
||||
@echo "verify: OK"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"ignorePatterns": [
|
||||
"node_modules",
|
||||
".next",
|
||||
".source",
|
||||
"out",
|
||||
"pnpm-lock.yaml",
|
||||
"public/openapi.json",
|
||||
// Reflowing MDX prose merges headings into paragraphs and collapses lists
|
||||
// inside JSX components (Steps/Callout). Author MDX by hand.
|
||||
"content/**/*.mdx",
|
||||
// Generated API reference pages (fumadocs-openapi output).
|
||||
"content/docs/**/reference/api"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
".next/**",
|
||||
".source/**",
|
||||
"out/**",
|
||||
"node_modules/**",
|
||||
"next-env.d.ts",
|
||||
"content/docs/**/reference/api/**"
|
||||
],
|
||||
"plugins": ["typescript", "react", "nextjs", "jsx-a11y", "import"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
"rules": {
|
||||
"no-var": "error",
|
||||
"prefer-const": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-unused-vars": "warn",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-require-imports": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-unused-expressions": "warn",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"import/no-anonymous-default-export": "warn",
|
||||
"jsx-a11y/prefer-tag-over-role": "off"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.source
|
||||
out
|
||||
pnpm-lock.yaml
|
||||
public/openapi.json
|
||||
# Don't let Prettier reflow MDX prose — it merges headings into paragraphs and
|
||||
# collapses lists inside JSX components (Steps/Callout). Author MDX by hand.
|
||||
content/**/*.mdx
|
||||
content/docs/**/reference/api
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -20,12 +20,12 @@ pnpm dev # http://localhost:3000
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm start` | Serve the production build |
|
||||
| `pnpm typecheck` | Generate MDX/route types and run `tsc --noEmit` |
|
||||
| `pnpm lint` | ESLint (flat config) |
|
||||
| `pnpm format` | Format with Prettier |
|
||||
| `pnpm lint` | oxlint (`.oxlintrc.json`) |
|
||||
| `pnpm format` | Format with oxfmt (`.oxfmtrc.json`) |
|
||||
| `pnpm test` | Run unit tests (Vitest) for `lib/xray/*` pure logic |
|
||||
| `pnpm gen:api` | Generate the API reference from `public/openapi.json` |
|
||||
|
||||
Before opening a pull request, please run `pnpm typecheck`, `pnpm lint`, and
|
||||
Before opening a pull request, please run `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and
|
||||
`pnpm test` — these are the same checks that CI runs on every PR.
|
||||
|
||||
## License
|
||||
|
||||
+16
-16
@@ -63,15 +63,15 @@ ever leaves your browser**:
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ---------- | ---------------------------------------------------------- |
|
||||
| Framework | [Next.js 16](https://nextjs.org) (App Router) · React 19 |
|
||||
| Docs | [Fumadocs](https://fumadocs.dev) (`-ui` / `-core` / `-mdx`) |
|
||||
| Styling | [Tailwind CSS v4](https://tailwindcss.com) |
|
||||
| Search | [Orama](https://orama.com) static index |
|
||||
| Language | TypeScript (strict) |
|
||||
| Tests | [Vitest](https://vitest.dev) for the pure `lib/xray` logic |
|
||||
| Tooling | pnpm · ESLint 9 · Prettier |
|
||||
| Layer | Technology |
|
||||
| --------- | ----------------------------------------------------------- |
|
||||
| Framework | [Next.js 16](https://nextjs.org) (App Router) · React 19 |
|
||||
| Docs | [Fumadocs](https://fumadocs.dev) (`-ui` / `-core` / `-mdx`) |
|
||||
| Styling | [Tailwind CSS v4](https://tailwindcss.com) |
|
||||
| Search | [Orama](https://orama.com) static index |
|
||||
| Language | TypeScript (strict) |
|
||||
| Tests | [Vitest](https://vitest.dev) for the pure `lib/xray` logic |
|
||||
| Tooling | pnpm · oxlint · oxfmt |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -86,13 +86,13 @@ pnpm dev # http://localhost:3000
|
||||
|
||||
Useful scripts:
|
||||
|
||||
| Script | Description |
|
||||
| ---------------- | -------------------------------------------- |
|
||||
| `pnpm dev` | Start the dev server |
|
||||
| `pnpm build` | Production build (also typechecks) |
|
||||
| `pnpm typecheck` | Generate MDX/route types and `tsc --noEmit` |
|
||||
| `pnpm lint` | Run ESLint |
|
||||
| `pnpm test` | Run unit tests (Vitest) |
|
||||
| Script | Description |
|
||||
| ---------------- | ------------------------------------------- |
|
||||
| `pnpm dev` | Start the dev server |
|
||||
| `pnpm build` | Production build (also typechecks) |
|
||||
| `pnpm typecheck` | Generate MDX/route types and `tsc --noEmit` |
|
||||
| `pnpm lint` | Run oxlint (`.oxlintrc.json`) |
|
||||
| `pnpm test` | Run unit tests (Vitest) |
|
||||
|
||||
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full list and project conventions.
|
||||
|
||||
|
||||
+100
-94
@@ -29,17 +29,17 @@ token), with a process restart as the fallback on older binaries.
|
||||
|
||||
Servers and processes, all launched from `main.go`:
|
||||
|
||||
| Server / process | Package | Purpose | Default port |
|
||||
|---|---|---|---|
|
||||
| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 |
|
||||
| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting |
|
||||
| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` |
|
||||
| **mtg-multi** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret) | per inbound |
|
||||
| Server / process | Package | Purpose | Default port |
|
||||
| ---------------- | --------------------------------- | ------------------------------------------------------------------ | ----------------- |
|
||||
| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 |
|
||||
| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting |
|
||||
| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` |
|
||||
| **mtg-multi** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret) | per inbound |
|
||||
|
||||
Two key ideas that explain most of the complexity:
|
||||
|
||||
1. **The DB → Xray config pipeline.** Inbounds/clients live in the DB. On every change the
|
||||
backend regenerates the Xray config and applies it — preferring a *hot diff* (live gRPC
|
||||
backend regenerates the Xray config and applies it — preferring a _hot diff_ (live gRPC
|
||||
API mutation) over a full process restart. See §5.1.
|
||||
2. **The Runtime abstraction (multi-node).** A panel can manage remote "nodes" (other 3x-ui
|
||||
instances). Every state-changing inbound/client operation is dispatched through a
|
||||
@@ -52,6 +52,7 @@ Two key ideas that explain most of the complexity:
|
||||
## 2. Tech stack
|
||||
|
||||
**Backend (Go 1.26):**
|
||||
|
||||
- Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
|
||||
- ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`).
|
||||
- Scheduler: **robfig/cron/v3** (seconds-precision) for all background jobs.
|
||||
@@ -61,6 +62,7 @@ Two key ideas that explain most of the complexity:
|
||||
- Misc: gorilla/websocket, gopsutil (system stats), go-qrcode, gotp (2FA TOTP).
|
||||
|
||||
**Frontend (`frontend/`):**
|
||||
|
||||
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
|
||||
- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas.
|
||||
- Router: **react-router 8**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
|
||||
@@ -95,7 +97,7 @@ Browser (React, fetch)
|
||||
```
|
||||
|
||||
The controller layer is thin. **Business logic lives in services.** When something is wrong
|
||||
with *behavior*, the bug is almost always in a service file, not a controller.
|
||||
with _behavior_, the bug is almost always in a service file, not a controller.
|
||||
|
||||
### 3.2 Subscription request (end-user fetching their config)
|
||||
|
||||
@@ -312,8 +314,8 @@ Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` /
|
||||
### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important
|
||||
|
||||
A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing
|
||||
inbound/client operation goes through the `runtime.Runtime` interface so the *same service
|
||||
code* works whether the target is the local Xray or a remote node.
|
||||
inbound/client operation goes through the `runtime.Runtime` interface so the _same service
|
||||
code_ works whether the target is the local Xray or a remote node.
|
||||
|
||||
- **Interface:** `internal/web/runtime/runtime.go` — `Name`, `AddInbound`, `DelInbound`,
|
||||
`UpdateInbound`, `AddUser`, `RemoveUser`, `UpdateUser`, `DeleteUser`, `AddClient`,
|
||||
@@ -329,7 +331,7 @@ code* works whether the target is the local Xray or a remote node.
|
||||
- **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
|
||||
otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
|
||||
|
||||
**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` *and* an
|
||||
**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` _and_ an
|
||||
`OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and
|
||||
online clients back to the originating panel using **stable GUIDs** rather than local IDs.
|
||||
Relevant logic: `service/inbound_node.go` (`ReconcileNode`, `SetRemoteTraffic`, GUID merge,
|
||||
@@ -338,6 +340,7 @@ tracking). Node "dirty" flags drive an **anti-entropy reconciliation** so an off
|
||||
inbound edits converge once it reconnects.
|
||||
|
||||
**Where to look for node bugs:**
|
||||
|
||||
- Operation not reaching a node → `runtime/remote.go` + `runtime/manager.go`.
|
||||
- Wrong traffic/online attribution across hops → `service/inbound_node.go` (GUID merge paths).
|
||||
- Node shown offline / stale status → `job/node_heartbeat_job.go` + `service/node.go` (`Probe`, `UpdateHeartbeat`).
|
||||
@@ -360,28 +363,28 @@ Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.Traffic
|
||||
|
||||
All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
|
||||
|
||||
| Schedule | Job | Purpose / condition |
|
||||
|---|---|---|
|
||||
| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) |
|
||||
| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) |
|
||||
| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) |
|
||||
| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) |
|
||||
| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation |
|
||||
| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits |
|
||||
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
|
||||
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
||||
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
|
||||
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
||||
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
|
||||
| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
|
||||
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
||||
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
|
||||
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
|
||||
| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` |
|
||||
| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` |
|
||||
| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS |
|
||||
| Schedule | Job | Purpose / condition |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) |
|
||||
| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) |
|
||||
| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) |
|
||||
| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) |
|
||||
| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation |
|
||||
| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits |
|
||||
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
|
||||
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
||||
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
|
||||
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
||||
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
|
||||
| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
|
||||
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
||||
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
|
||||
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
|
||||
| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` |
|
||||
| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` |
|
||||
| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS |
|
||||
|
||||
To change *when* something runs, edit `startTask()`. To change *what* it does, edit the job file.
|
||||
To change _when_ something runs, edit `startTask()`. To change _what_ it does, edit the job file.
|
||||
|
||||
### 5.5 Type generation (Go → TypeScript) ⚠️ don't hand-edit generated files
|
||||
|
||||
@@ -400,8 +403,9 @@ frontend types (`cd frontend && npm run gen`) instead of editing `src/generated/
|
||||
### 5.6 Share-link / subscription generation
|
||||
|
||||
Two distinct code paths produce client configs:
|
||||
|
||||
- **Per-client links in the panel** (the "copy link" / QR in the UI): `service/client_link.go`
|
||||
+ `util/link/outbound.go`.
|
||||
- `util/link/outbound.go`.
|
||||
- **Subscription endpoint** (what a client app polls): `internal/sub/service.go` (raw links),
|
||||
`internal/sub/json_service.go` (JSON), `internal/sub/clash_service.go` (Clash YAML).
|
||||
**`Host` rows** (`model.Host`, edited under /panel/api/hosts) override address/SNI/path/
|
||||
@@ -438,70 +442,70 @@ Xray restart.
|
||||
GORM models in `internal/database/model/` (main file `model.go` + siblings); all registered
|
||||
for AutoMigrate in `internal/database/db.go`.
|
||||
|
||||
| Model | Table role | Notable fields |
|
||||
|---|---|---|
|
||||
| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) |
|
||||
| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) |
|
||||
| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) |
|
||||
| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` |
|
||||
| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` |
|
||||
| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` |
|
||||
| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags |
|
||||
| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields |
|
||||
| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) |
|
||||
| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` |
|
||||
| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` |
|
||||
| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` |
|
||||
| `InboundClientIps` | IP set per client email | drives IP-limit enforcement |
|
||||
| `OutboundTraffics` | Outbound counters | per outbound tag |
|
||||
| `OutboundSubscription` | External provider subs | Warp/Nord style |
|
||||
| `Setting` | Key/value panel settings | everything configurable |
|
||||
| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) |
|
||||
| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest |
|
||||
| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations |
|
||||
| Model | Table role | Notable fields |
|
||||
| ------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) |
|
||||
| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) |
|
||||
| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) |
|
||||
| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` |
|
||||
| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` |
|
||||
| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` |
|
||||
| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags |
|
||||
| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields |
|
||||
| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) |
|
||||
| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` |
|
||||
| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` |
|
||||
| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` |
|
||||
| `InboundClientIps` | IP set per client email | drives IP-limit enforcement |
|
||||
| `OutboundTraffics` | Outbound counters | per outbound tag |
|
||||
| `OutboundSubscription` | External provider subs | Warp/Nord style |
|
||||
| `Setting` | Key/value panel settings | everything configurable |
|
||||
| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) |
|
||||
| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest |
|
||||
| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations |
|
||||
|
||||
---
|
||||
|
||||
## 7. Symptom → File index (start here when debugging)
|
||||
|
||||
| Symptom / task | Primary file(s) | Then check |
|
||||
|---|---|---|
|
||||
| Add/modify an **API endpoint** | `controller/<resource>.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` |
|
||||
| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` |
|
||||
| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` |
|
||||
| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` |
|
||||
| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` |
|
||||
| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` |
|
||||
| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` |
|
||||
| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` |
|
||||
| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` |
|
||||
| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) |
|
||||
| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) |
|
||||
| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) |
|
||||
| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` |
|
||||
| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests |
|
||||
| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` |
|
||||
| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` |
|
||||
| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` |
|
||||
| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` |
|
||||
| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` |
|
||||
| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` |
|
||||
| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` |
|
||||
| **Geo category browser** empty / won't open | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go` | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` |
|
||||
| **`geosite:`/`geoip:` token** reported unknown in a routing rule | `xray/geodata/token.go`, `service/geodata.go` (`Validate`) | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/` |
|
||||
| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` |
|
||||
| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
|
||||
| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |
|
||||
| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` |
|
||||
| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` |
|
||||
| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` |
|
||||
| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` |
|
||||
| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` |
|
||||
| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) |
|
||||
| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` |
|
||||
| **Frontend route / screen** | `frontend/src/pages/<area>/`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` |
|
||||
| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` |
|
||||
| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil |
|
||||
| Symptom / task | Primary file(s) | Then check |
|
||||
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Add/modify an **API endpoint** | `controller/<resource>.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` |
|
||||
| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` |
|
||||
| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` |
|
||||
| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` |
|
||||
| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` |
|
||||
| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` |
|
||||
| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` |
|
||||
| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` |
|
||||
| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` |
|
||||
| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) |
|
||||
| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) |
|
||||
| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) |
|
||||
| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` |
|
||||
| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests |
|
||||
| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` |
|
||||
| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` |
|
||||
| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` |
|
||||
| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` |
|
||||
| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` |
|
||||
| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` |
|
||||
| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` |
|
||||
| **Geo category browser** empty / won't open | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go` | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` |
|
||||
| **`geosite:`/`geoip:` token** reported unknown in a routing rule | `xray/geodata/token.go`, `service/geodata.go` (`Validate`) | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/` |
|
||||
| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` |
|
||||
| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
|
||||
| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |
|
||||
| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` |
|
||||
| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` |
|
||||
| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` |
|
||||
| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` |
|
||||
| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` |
|
||||
| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) |
|
||||
| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` |
|
||||
| **Frontend route / screen** | `frontend/src/pages/<area>/`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` |
|
||||
| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` |
|
||||
| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil |
|
||||
|
||||
---
|
||||
|
||||
@@ -522,7 +526,7 @@ for AutoMigrate in `internal/database/db.go`.
|
||||
Regenerate instead.
|
||||
7. **Models are the contract.** Changing a model field that crosses the API boundary means:
|
||||
update `model.go` → handle migration in `db.go`/`migrate_data.go` → regenerate frontend types.
|
||||
8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an *end user*
|
||||
8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an _end user_
|
||||
fetches goes in `internal/sub`. Don't blur them.
|
||||
9. **Cross-cutting notifications go through `internal/eventbus/`** — publish an event instead
|
||||
of importing the Telegram/email services into producers.
|
||||
@@ -536,6 +540,7 @@ The canonical gate is the **Makefile** (mirrors CI): `make verify`. Also: `make
|
||||
frontend), `make race`, `make build`. Run `make help` for everything. Raw commands:
|
||||
|
||||
**Backend (Go):**
|
||||
|
||||
```bash
|
||||
go build ./... # compile everything
|
||||
go test ./... # run all Go tests (many *_test.go alongside sources)
|
||||
@@ -548,11 +553,12 @@ go run main.go # run the panel locally (serves embedded dis
|
||||
```
|
||||
|
||||
**Frontend (`cd frontend`, Node 24 — see `.nvmrc`):**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint src
|
||||
npm run lint # oxlint src
|
||||
npm run test # vitest (incl. golden config-generation snapshots)
|
||||
npm run gen # regenerate src/generated/* from Go (gen:zod + gen:api)
|
||||
npm run build # gen:api + vite build → outputs to internal/web/dist (then rebuild Go binary to embed)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { buildCurl, buildFetchSnippet, type ApiRequestInput, type HttpMethod } from '@/lib/xray/api-client';
|
||||
import {
|
||||
buildCurl,
|
||||
buildFetchSnippet,
|
||||
type ApiRequestInput,
|
||||
type HttpMethod,
|
||||
} from '@/lib/xray/api-client';
|
||||
import { ToolFrame } from './tool-frame';
|
||||
import { TextField, SelectField } from './shared/fields';
|
||||
import { OutputBlock } from './shared/output-block';
|
||||
|
||||
@@ -38,8 +38,24 @@ const DEFAULT_BALANCERS: BalancerRow[] = [
|
||||
{ tag: 'balancer', selector: 'proxy', strategy: 'leastPing', fallbackTag: '' },
|
||||
];
|
||||
const DEFAULT_RULES: RuleRow[] = [
|
||||
{ domain: 'geosite:category-ads-all', ip: '', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: 'block' },
|
||||
{ domain: '', ip: 'geoip:private', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: 'direct' },
|
||||
{
|
||||
domain: 'geosite:category-ads-all',
|
||||
ip: '',
|
||||
port: '',
|
||||
network: 'any',
|
||||
inboundTag: '',
|
||||
targetKind: 'outbound',
|
||||
targetTag: 'block',
|
||||
},
|
||||
{
|
||||
domain: '',
|
||||
ip: 'geoip:private',
|
||||
port: '',
|
||||
network: 'any',
|
||||
inboundTag: '',
|
||||
targetKind: 'outbound',
|
||||
targetTag: 'direct',
|
||||
},
|
||||
];
|
||||
|
||||
function list(s: string): string[] {
|
||||
@@ -113,7 +129,10 @@ export function RoutingBuilder() {
|
||||
type="button"
|
||||
className={addBtn}
|
||||
onClick={() =>
|
||||
setBalancers((p) => [...p, { tag: '', selector: '', strategy: 'random', fallbackTag: '' }])
|
||||
setBalancers((p) => [
|
||||
...p,
|
||||
{ tag: '', selector: '', strategy: 'random', fallbackTag: '' },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add balancer
|
||||
@@ -163,7 +182,15 @@ export function RoutingBuilder() {
|
||||
onClick={() =>
|
||||
setRules((p) => [
|
||||
...p,
|
||||
{ domain: '', ip: '', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: '' },
|
||||
{
|
||||
domain: '',
|
||||
ip: '',
|
||||
port: '',
|
||||
network: 'any',
|
||||
inboundTag: '',
|
||||
targetKind: 'outbound',
|
||||
targetTag: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
@@ -174,13 +201,47 @@ export function RoutingBuilder() {
|
||||
{rules.map((r, i) => (
|
||||
<div key={i} className="rounded-xl border p-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Domain (comma)" value={r.domain} onChange={(v) => patchRule(i, { domain: v })} placeholder="geosite:google, example.com" />
|
||||
<TextField label="IP (comma)" value={r.ip} onChange={(v) => patchRule(i, { ip: v })} placeholder="geoip:cn, 1.1.1.1" />
|
||||
<TextField label="Port" value={r.port} onChange={(v) => patchRule(i, { port: v })} placeholder="443 or 1000-2000" />
|
||||
<SelectField label="Network" value={r.network} onChange={(v) => patchRule(i, { network: v })} options={NETWORKS} />
|
||||
<TextField label="Inbound tag (comma)" value={r.inboundTag} onChange={(v) => patchRule(i, { inboundTag: v })} placeholder="optional" />
|
||||
<SelectField label="Target kind" value={r.targetKind} onChange={(v) => patchRule(i, { targetKind: v as 'outbound' | 'balancer' })} options={TARGET_KINDS} />
|
||||
<TextField label="Target tag" value={r.targetTag} onChange={(v) => patchRule(i, { targetTag: v })} />
|
||||
<TextField
|
||||
label="Domain (comma)"
|
||||
value={r.domain}
|
||||
onChange={(v) => patchRule(i, { domain: v })}
|
||||
placeholder="geosite:google, example.com"
|
||||
/>
|
||||
<TextField
|
||||
label="IP (comma)"
|
||||
value={r.ip}
|
||||
onChange={(v) => patchRule(i, { ip: v })}
|
||||
placeholder="geoip:cn, 1.1.1.1"
|
||||
/>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={r.port}
|
||||
onChange={(v) => patchRule(i, { port: v })}
|
||||
placeholder="443 or 1000-2000"
|
||||
/>
|
||||
<SelectField
|
||||
label="Network"
|
||||
value={r.network}
|
||||
onChange={(v) => patchRule(i, { network: v })}
|
||||
options={NETWORKS}
|
||||
/>
|
||||
<TextField
|
||||
label="Inbound tag (comma)"
|
||||
value={r.inboundTag}
|
||||
onChange={(v) => patchRule(i, { inboundTag: v })}
|
||||
placeholder="optional"
|
||||
/>
|
||||
<SelectField
|
||||
label="Target kind"
|
||||
value={r.targetKind}
|
||||
onChange={(v) => patchRule(i, { targetKind: v as 'outbound' | 'balancer' })}
|
||||
options={TARGET_KINDS}
|
||||
/>
|
||||
<TextField
|
||||
label="Target tag"
|
||||
value={r.targetTag}
|
||||
onChange={(v) => patchRule(i, { targetTag: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -79,7 +79,15 @@ export function SubscriptionBuilder() {
|
||||
setClients((prev) => prev.map((c, j) => (i === j ? { ...c, ...p } : c)));
|
||||
}
|
||||
|
||||
const urlInput: SubUrlInput = { scheme, host, port: Number(port), subPath, jsonPath, subId, behindProxy };
|
||||
const urlInput: SubUrlInput = {
|
||||
scheme,
|
||||
host,
|
||||
port: Number(port),
|
||||
subPath,
|
||||
jsonPath,
|
||||
subId,
|
||||
behindProxy,
|
||||
};
|
||||
const urls = buildSubscriptionUrls(urlInput);
|
||||
const subClients = clients.filter((c) => c.address.trim()).map(toClient);
|
||||
|
||||
@@ -159,16 +167,33 @@ export function SubscriptionBuilder() {
|
||||
onChange={(v) => patch(i, { protocol: v as ClientProtocol })}
|
||||
options={PROTOCOLS}
|
||||
/>
|
||||
<TextField label="Remark" value={c.remark} onChange={(v) => patch(i, { remark: v })} />
|
||||
<TextField label="Address" value={c.address} onChange={(v) => patch(i, { address: v })} />
|
||||
<TextField label="Port" value={c.port} onChange={(v) => patch(i, { port: v })} inputMode="numeric" />
|
||||
<TextField
|
||||
label="Remark"
|
||||
value={c.remark}
|
||||
onChange={(v) => patch(i, { remark: v })}
|
||||
/>
|
||||
<TextField
|
||||
label="Address"
|
||||
value={c.address}
|
||||
onChange={(v) => patch(i, { address: v })}
|
||||
/>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={c.port}
|
||||
onChange={(v) => patch(i, { port: v })}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<TextField
|
||||
label={c.protocol === 'vless' || c.protocol === 'vmess' ? 'UUID (id)' : 'Password'}
|
||||
value={c.credential}
|
||||
onChange={(v) => patch(i, { credential: v })}
|
||||
/>
|
||||
{c.protocol === 'ss' ? (
|
||||
<TextField label="Method" value={c.method} onChange={(v) => patch(i, { method: v })} />
|
||||
<TextField
|
||||
label="Method"
|
||||
value={c.method}
|
||||
onChange={(v) => patch(i, { method: v })}
|
||||
/>
|
||||
) : null}
|
||||
<SelectField
|
||||
label="Transport"
|
||||
@@ -200,9 +225,15 @@ export function SubscriptionBuilder() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4">
|
||||
<OutputBlock label="Subscription links (decoded body)" value={buildShareLinks(subClients).join('\n')} />
|
||||
<OutputBlock
|
||||
label="Subscription links (decoded body)"
|
||||
value={buildShareLinks(subClients).join('\n')}
|
||||
/>
|
||||
<OutputBlock label="Base64 body" value={buildBase64Subscription(subClients)} />
|
||||
<OutputBlock label="JSON subscription (preview)" value={buildJsonSubscription(subClients)} />
|
||||
<OutputBlock
|
||||
label="JSON subscription (preview)"
|
||||
value={buildJsonSubscription(subClients)}
|
||||
/>
|
||||
</div>
|
||||
</ToolFrame>
|
||||
);
|
||||
|
||||
@@ -22,28 +22,28 @@ The panel uses standard Go `html/template` to render the subscription page.
|
||||
|
||||
When rendering the template, the following variables are injected into the template context (`{{ .variable }}`):
|
||||
|
||||
* `{{ .sId }}`: Subscription ID (UUID).
|
||||
* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
|
||||
* `{{ .isOnline }}`: Whether the subscription's client has a live connection right now (boolean). Computed from the panel's online-client tracking (local Xray plus any remote nodes) at render time.
|
||||
* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
|
||||
* `{{ .upload }}`: Formatted upload traffic.
|
||||
* `{{ .total }}`: Formatted total traffic limit.
|
||||
* `{{ .used }}`: Formatted used traffic (download + upload).
|
||||
* `{{ .remained }}`: Formatted remaining traffic.
|
||||
* `{{ .expire }}`: Expiration time as an int64 Unix timestamp in **seconds** (`0` means never). Multiply by 1000 for a JavaScript `Date`.
|
||||
* `{{ .lastOnline }}`: Last online time as an int64 Unix timestamp in **milliseconds** (`0` means never seen).
|
||||
* `{{ .downloadByte }}`: Download traffic in exact bytes (int64).
|
||||
* `{{ .uploadByte }}`: Upload traffic in exact bytes (int64).
|
||||
* `{{ .totalByte }}`: Total traffic limit in exact bytes (int64).
|
||||
* `{{ .subUrl }}`: The URL of the subscription page.
|
||||
* `{{ .subJsonUrl }}`: The URL for the JSON configuration of the subscription.
|
||||
* `{{ .subClashUrl }}`: The URL for the Clash/Mihomo configuration.
|
||||
* `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
|
||||
* `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
|
||||
* `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
|
||||
* `{{ .emails }}`: A list (slice) of client emails, parallel to `links` — the email at index *i* owns the link at index *i*. May contain duplicates when one client has several links.
|
||||
* `{{ .announce }}`: The announcement text configured in the panel (Settings → Subscription → Announce). May be empty.
|
||||
* `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").
|
||||
- `{{ .sId }}`: Subscription ID (UUID).
|
||||
- `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
|
||||
- `{{ .isOnline }}`: Whether the subscription's client has a live connection right now (boolean). Computed from the panel's online-client tracking (local Xray plus any remote nodes) at render time.
|
||||
- `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
|
||||
- `{{ .upload }}`: Formatted upload traffic.
|
||||
- `{{ .total }}`: Formatted total traffic limit.
|
||||
- `{{ .used }}`: Formatted used traffic (download + upload).
|
||||
- `{{ .remained }}`: Formatted remaining traffic.
|
||||
- `{{ .expire }}`: Expiration time as an int64 Unix timestamp in **seconds** (`0` means never). Multiply by 1000 for a JavaScript `Date`.
|
||||
- `{{ .lastOnline }}`: Last online time as an int64 Unix timestamp in **milliseconds** (`0` means never seen).
|
||||
- `{{ .downloadByte }}`: Download traffic in exact bytes (int64).
|
||||
- `{{ .uploadByte }}`: Upload traffic in exact bytes (int64).
|
||||
- `{{ .totalByte }}`: Total traffic limit in exact bytes (int64).
|
||||
- `{{ .subUrl }}`: The URL of the subscription page.
|
||||
- `{{ .subJsonUrl }}`: The URL for the JSON configuration of the subscription.
|
||||
- `{{ .subClashUrl }}`: The URL for the Clash/Mihomo configuration.
|
||||
- `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
|
||||
- `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
|
||||
- `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
|
||||
- `{{ .emails }}`: A list (slice) of client emails, parallel to `links` — the email at index _i_ owns the link at index _i_. May contain duplicates when one client has several links.
|
||||
- `{{ .announce }}`: The announcement text configured in the panel (Settings → Subscription → Announce). May be empty.
|
||||
- `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").
|
||||
|
||||
## Live Status JSON (`?format=info`)
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import coreWebVitals from 'eslint-config-next/core-web-vitals';
|
||||
import typescript from 'eslint-config-next/typescript';
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
const config = [
|
||||
{
|
||||
ignores: [
|
||||
'.next/**',
|
||||
'.source/**',
|
||||
'out/**',
|
||||
'node_modules/**',
|
||||
'next-env.d.ts',
|
||||
// Generated API reference pages (fumadocs-openapi output)
|
||||
'content/docs/**/reference/api/**',
|
||||
],
|
||||
},
|
||||
...coreWebVitals,
|
||||
...typescript,
|
||||
];
|
||||
|
||||
export default config;
|
||||
@@ -3,7 +3,14 @@ import { Heart } from 'lucide-react';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { TelegramIcon } from '@/components/icons';
|
||||
import { DocsThemeSwitch } from '@/components/theme-switch';
|
||||
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } 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'`,
|
||||
|
||||
@@ -222,7 +222,8 @@ const zh: SiteMessages = {
|
||||
},
|
||||
{
|
||||
title: '自托管且可脚本化',
|
||||
description: '单个 Go 二进制文件或 Docker 镜像、SQLite/PostgreSQL 后端,以及用于自动化的完整 REST API。',
|
||||
description:
|
||||
'单个 Go 二进制文件或 Docker 镜像、SQLite/PostgreSQL 后端,以及用于自动化的完整 REST API。',
|
||||
},
|
||||
],
|
||||
licenseBefore: '基于 ',
|
||||
|
||||
@@ -31,7 +31,7 @@ const base = {
|
||||
describe('buildCurl', () => {
|
||||
it('GET emits the Bearer header, a single-quoted URL, and no body flag', () => {
|
||||
const cmd = buildCurl({ ...base, method: 'GET' });
|
||||
expect(cmd).toContain("-X GET");
|
||||
expect(cmd).toContain('-X GET');
|
||||
expect(cmd).toContain("-H 'Authorization: Bearer TKN'");
|
||||
expect(cmd).toContain("'https://panel.example.com:2053/panel/api/inbounds/list'");
|
||||
expect(cmd).not.toContain('--data');
|
||||
@@ -39,14 +39,23 @@ describe('buildCurl', () => {
|
||||
});
|
||||
|
||||
it('POST with a body emits --data and a JSON content type', () => {
|
||||
const cmd = buildCurl({ ...base, method: 'POST', path: '/panel/api/inbounds/add', body: '{"up":0}' });
|
||||
const cmd = buildCurl({
|
||||
...base,
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
body: '{"up":0}',
|
||||
});
|
||||
expect(cmd).toContain('-X POST');
|
||||
expect(cmd).toContain("--data '{\"up\":0}'");
|
||||
expect(cmd).toContain("Content-Type: application/json");
|
||||
expect(cmd).toContain('--data \'{"up":0}\'');
|
||||
expect(cmd).toContain('Content-Type: application/json');
|
||||
});
|
||||
|
||||
it('POST without a body omits --data', () => {
|
||||
const cmd = buildCurl({ ...base, method: 'POST', path: '/panel/api/inbounds/resetAllTraffics' });
|
||||
const cmd = buildCurl({
|
||||
...base,
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllTraffics',
|
||||
});
|
||||
expect(cmd).not.toContain('--data');
|
||||
});
|
||||
});
|
||||
@@ -60,7 +69,12 @@ describe('buildFetchSnippet', () => {
|
||||
});
|
||||
|
||||
it('POST with a body includes a JSON.stringify body', () => {
|
||||
const snip = buildFetchSnippet({ ...base, method: 'POST', path: '/panel/api/inbounds/add', body: '{"up":0}' });
|
||||
const snip = buildFetchSnippet({
|
||||
...base,
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
body: '{"up":0}',
|
||||
});
|
||||
expect(snip).toContain("method: 'POST'");
|
||||
expect(snip).toContain('body: JSON.stringify(');
|
||||
});
|
||||
|
||||
@@ -160,7 +160,12 @@ describe('buildOutbound — wireguard & warp', () => {
|
||||
const ob = buildOutbound({
|
||||
kind: 'wireguard',
|
||||
tag: 'wg',
|
||||
wireguard: { secretKey: 'sk', address: ['10.0.0.2/32'], publicKey: 'pk', endpoint: 'host:51820' },
|
||||
wireguard: {
|
||||
secretKey: 'sk',
|
||||
address: ['10.0.0.2/32'],
|
||||
publicKey: 'pk',
|
||||
endpoint: 'host:51820',
|
||||
},
|
||||
});
|
||||
const s = ob.settings as Record<string, unknown>;
|
||||
expect(s.secretKey).toBe('sk');
|
||||
|
||||
@@ -162,7 +162,11 @@ function buildSettings(o: OutboundInput): Record<string, unknown> {
|
||||
],
|
||||
};
|
||||
case 'trojan':
|
||||
return { servers: [{ address: s?.address ?? '', port: toPort(s?.port), password: s?.password ?? '' }] };
|
||||
return {
|
||||
servers: [
|
||||
{ address: s?.address ?? '', port: toPort(s?.port), password: s?.password ?? '' },
|
||||
],
|
||||
};
|
||||
case 'shadowsocks':
|
||||
return {
|
||||
servers: [
|
||||
|
||||
@@ -18,7 +18,12 @@ describe('buildBalancer', () => {
|
||||
});
|
||||
|
||||
it('includes fallbackTag when set', () => {
|
||||
const b = buildBalancer({ tag: 'lb', selector: ['a'], strategy: 'random', fallbackTag: 'direct' });
|
||||
const b = buildBalancer({
|
||||
tag: 'lb',
|
||||
selector: ['a'],
|
||||
strategy: 'random',
|
||||
fallbackTag: 'direct',
|
||||
});
|
||||
expect(b.fallbackTag).toBe('direct');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,7 +121,10 @@ export function buildRouting(input: RoutingInput): Record<string, unknown> {
|
||||
if (input.observatory) {
|
||||
Object.assign(out, buildObservatory(input.observatory));
|
||||
} else if (input.balancers.some((b) => b.strategy === 'leastLoad')) {
|
||||
Object.assign(out, buildObservatory({ mode: 'burst', subjectSelector: uniqueSelectors(input.balancers) }));
|
||||
Object.assign(
|
||||
out,
|
||||
buildObservatory({ mode: 'burst', subjectSelector: uniqueSelectors(input.balancers) }),
|
||||
);
|
||||
} else if (input.balancers.some((b) => b.strategy === 'leastPing')) {
|
||||
Object.assign(
|
||||
out,
|
||||
|
||||
@@ -214,12 +214,20 @@ function proxyOutbound(c: SubClient): Record<string, unknown> {
|
||||
};
|
||||
break;
|
||||
case 'trojan':
|
||||
settings = { servers: [{ address: c.address, port: c.port, password: c.password ?? '', level: 8 }] };
|
||||
settings = {
|
||||
servers: [{ address: c.address, port: c.port, password: c.password ?? '', level: 8 }],
|
||||
};
|
||||
break;
|
||||
case 'ss':
|
||||
settings = {
|
||||
servers: [
|
||||
{ address: c.address, port: c.port, password: c.password ?? '', level: 8, method: c.method || '' },
|
||||
{
|
||||
address: c.address,
|
||||
port: c.port,
|
||||
password: c.password ?? '',
|
||||
level: 8,
|
||||
method: c.method || '',
|
||||
},
|
||||
],
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -36,7 +36,10 @@ describe('parseAdminIds', () => {
|
||||
});
|
||||
|
||||
it('accepts negative group ids and captures invalid entries', () => {
|
||||
expect(parseAdminIds('-1001234567, abc, 42')).toEqual({ ids: [-1001234567, 42], invalid: ['abc'] });
|
||||
expect(parseAdminIds('-1001234567, abc, 42')).toEqual({
|
||||
ids: [-1001234567, 42],
|
||||
invalid: ['abc'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty for blank input', () => {
|
||||
@@ -78,9 +81,9 @@ describe('telegramApiBase', () => {
|
||||
|
||||
describe('renderMessageTemplate', () => {
|
||||
it('substitutes known variables', () => {
|
||||
expect(renderMessageTemplate('Host {{host}} up {{uptime}}', { host: 'srv', uptime: '3d' })).toBe(
|
||||
'Host srv up 3d',
|
||||
);
|
||||
expect(
|
||||
renderMessageTemplate('Host {{host}} up {{uptime}}', { host: 'srv', uptime: '3d' }),
|
||||
).toBe('Host srv up 3d');
|
||||
});
|
||||
|
||||
it('leaves unknown variables literal', () => {
|
||||
@@ -90,7 +93,11 @@ describe('renderMessageTemplate', () => {
|
||||
|
||||
describe('buildBotConfigSummary', () => {
|
||||
it('emits the panel settings keys with admin ids joined', () => {
|
||||
const s = buildBotConfigSummary({ token: VALID_TOKEN, adminIds: '111, 222', runTime: '@daily' });
|
||||
const s = buildBotConfigSummary({
|
||||
token: VALID_TOKEN,
|
||||
adminIds: '111, 222',
|
||||
runTime: '@daily',
|
||||
});
|
||||
expect(s.tgBotEnable).toBe(true);
|
||||
expect(s.tgBotToken).toBe(VALID_TOKEN);
|
||||
expect(s.tgBotChatId).toBe('111,222');
|
||||
|
||||
@@ -43,7 +43,10 @@ export function validateBotToken(token: string): TokenValidation {
|
||||
export function parseAdminIds(raw: string): AdminIdsResult {
|
||||
const ids: number[] = [];
|
||||
const invalid: string[] = [];
|
||||
for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
for (const part of raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)) {
|
||||
// Telegram chat ids are integers; group/channel ids are negative.
|
||||
if (/^-?\d+$/.test(part)) ids.push(Number(part));
|
||||
else invalid.push(part);
|
||||
|
||||
+6
-8
@@ -11,9 +11,9 @@
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"gen:api": "node scripts/gen-openapi.ts",
|
||||
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "oxlint .",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
@@ -41,13 +41,11 @@
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"oxfmt": "0.63.0",
|
||||
"oxlint": "1.78.0",
|
||||
"postcss": "^8.5.26",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1"
|
||||
|
||||
Generated
+664
-2844
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -14,11 +14,11 @@ list, and multi-node sync — so once it is set, everything downstream just work
|
||||
Open an inbound → **Transport / Stream Settings** → enable **Sockopt** → use the
|
||||
**Real client IP** preset selector:
|
||||
|
||||
| Preset | What it does | Use for |
|
||||
|---|---|---|
|
||||
| **Off / direct** | Clears both fields. | Inbound reachable directly by clients. |
|
||||
| **Cloudflare CDN** | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). |
|
||||
| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`. | An L4 tunnel/relay in front, or Cloudflare **Spectrum**. |
|
||||
| Preset | What it does | Use for |
|
||||
| ------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| **Off / direct** | Clears both fields. | Inbound reachable directly by clients. |
|
||||
| **Cloudflare CDN** | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). |
|
||||
| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`. | An L4 tunnel/relay in front, or Cloudflare **Spectrum**. |
|
||||
|
||||
The raw `Proxy Protocol` switch and `Trusted X-Forwarded-For` list stay visible below the preset
|
||||
selector for manual / advanced tuning — the presets just fill them in for you.
|
||||
@@ -65,16 +65,16 @@ and XHTTP; **not** on mKCP. The front must be configured to send the header, e.g
|
||||
|
||||
## Transport support matrix
|
||||
|
||||
| Mechanism | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP |
|
||||
|---|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| `trustedXForwardedFor` (header) | – | – | ✅ | – | ✅ | ✅ |
|
||||
| `acceptProxyProtocol` (PROXY) | ✅ | – | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mechanism | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP |
|
||||
| ------------------------------- | :-----: | :--: | :-------: | :--: | :---------: | :---: |
|
||||
| `trustedXForwardedFor` (header) | – | – | ✅ | – | ✅ | ✅ |
|
||||
| `acceptProxyProtocol` (PROXY) | ✅ | – | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
The form shows a warning when you select a preset that the current transport cannot honor.
|
||||
|
||||
> **Use one, not both.** `acceptProxyProtocol` and `trustedXForwardedFor` are independent — the
|
||||
> first reads the real IP from the L4 PROXY header, the second from an HTTP request header. On
|
||||
> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header *last*, so a stale
|
||||
> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header _last_, so a stale
|
||||
> `trustedXForwardedFor` would override (and defeat) a PROXY-protocol setup. The presets are
|
||||
> mutually exclusive and clear the other field for you; only mix them by hand if you know your
|
||||
> upstream chain needs it.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"ignorePatterns": [
|
||||
"node_modules",
|
||||
"src/generated",
|
||||
"public",
|
||||
"tools/oxlint/__fixtures__"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
"node_modules/**"
|
||||
],
|
||||
"plugins": [
|
||||
"typescript",
|
||||
"react",
|
||||
"jsx-a11y"
|
||||
],
|
||||
"jsPlugins": [
|
||||
"./tools/oxlint/input-number-guard.mjs"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-require-imports": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-unused-expressions": "warn",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"no-empty": [
|
||||
"error",
|
||||
{
|
||||
"allowEmptyCatch": true
|
||||
}
|
||||
],
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
"jsx-a11y/no-autofocus": "off",
|
||||
"input-number/no-synthetic-clear": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"src/pages/settings/**/*.tsx",
|
||||
"src/pages/xray/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"input-number/no-synthetic-clear": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/pages/xray/**/*Modal.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"input-number/no-synthetic-clear": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ The `@` import alias maps to `src/`.
|
||||
- Function components + hooks only; no class components.
|
||||
- Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the
|
||||
*why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine.
|
||||
- TS strict; `no-explicit-any` is an error. Build forms with `useZodForm` +
|
||||
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with `useZodForm` +
|
||||
`FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
|
||||
validate through the `zodResolver` or per-field
|
||||
`rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
|
||||
|
||||
+18
-11
@@ -33,7 +33,10 @@ production-style links work without round-tripping through Go.
|
||||
| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../internal/web/dist/` |
|
||||
| `npm run preview` | Serve the built bundle locally |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
|
||||
| `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
|
||||
| `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) |
|
||||
| `npm run lint:deprecated` | Type-aware sweep for JSDoc `@deprecated` APIs (on demand) |
|
||||
| `npm run format` | oxfmt (`.oxfmtrc.json`) — rewrites `src/` + `tools/` in place |
|
||||
| `npm run format:check` | oxfmt in check mode (no writes) |
|
||||
| `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
|
||||
| `npm run test:watch` | Vitest watch mode |
|
||||
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
|
||||
@@ -41,8 +44,8 @@ production-style links work without round-tripping through Go.
|
||||
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
|
||||
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
|
||||
|
||||
CI runs `typecheck`, `lint`, `test`, `build`, and `build-storybook` on
|
||||
every PR (see `../.github/workflows/ci.yml`).
|
||||
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
|
||||
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
|
||||
|
||||
### One-off: scan for deprecated APIs
|
||||
|
||||
@@ -51,12 +54,13 @@ with the JSDoc `@deprecated` tag (AntD prop renames, Zod renames,
|
||||
removed Web APIs, etc.):
|
||||
|
||||
```sh
|
||||
npx eslint --config eslint.deprecated.config.js src
|
||||
npm run lint:deprecated
|
||||
```
|
||||
|
||||
It's a type-aware ESLint run against `eslint.deprecated.config.js`
|
||||
and is not wired into `npm run lint` because typed linting triples
|
||||
the wall-clock time.
|
||||
It is oxlint's type-aware mode (`oxlint-tsgolint`, which drives the
|
||||
TypeScript 7 `typescript-go` checker) narrowed to `no-deprecated`, and
|
||||
is not wired into `npm run lint` because typed linting needs a full
|
||||
type-check pass.
|
||||
|
||||
## Production build
|
||||
|
||||
@@ -85,9 +89,12 @@ normal network requests.
|
||||
frontend/
|
||||
├── index.html, login.html, subpage.html # 3 Vite entries
|
||||
├── tsconfig.json
|
||||
├── eslint.config.js
|
||||
├── eslint.deprecated.config.js # On-demand type-aware lint config that flags
|
||||
│ # usages of APIs marked with JSDoc @deprecated
|
||||
├── .oxlintrc.json # oxlint config (replaces the ESLint flat config)
|
||||
├── .oxfmtrc.json # oxfmt config (Prettier-compatible settings)
|
||||
├── tools/oxlint/
|
||||
│ └── input-number-guard.mjs # oxlint JS plugin: the #6121/#6127 cleared-
|
||||
│ # InputNumber guard (oxlint has no
|
||||
│ # no-restricted-syntax)
|
||||
├── vitest.config.ts
|
||||
├── vite.config.js
|
||||
├── .storybook/ # Storybook config (main.ts, preview.tsx)
|
||||
@@ -155,7 +162,7 @@ Patterns:
|
||||
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
|
||||
because a malformed payload here is always a developer bug
|
||||
- **No `.loose()` or `[key: string]: any`** in production schemas.
|
||||
`@typescript-eslint/no-explicit-any: error` is enforced.
|
||||
`typescript/no-explicit-any: error` is enforced by oxlint.
|
||||
|
||||
## Form pattern (Pattern A)
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', '../internal/web/dist/**'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended.map((config) => ({
|
||||
...config,
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
})),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
// Zod migration goal (Step 7): every production module is held to
|
||||
// strict no-explicit-any. The two legacy class files at the bottom
|
||||
// of the rule list keep their existing file-level eslint-disable
|
||||
// until DBInbound is migrated off Inbound.toInbound() — see the
|
||||
// migration spec Non-Goals section.
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/react-compiler': 'off',
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
'react-hooks/immutability': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.tsx'],
|
||||
plugins: { 'jsx-a11y': jsxA11y },
|
||||
rules: {
|
||||
...jsxA11y.flatConfigs.recommended.rules,
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// The settings and xray pages write numeric InputNumber changes straight
|
||||
// into state, so a null-collapsing handler (`Number(v) || N`, or the
|
||||
// ternary `typeof v === 'number' ? v : N`) turns a cleared field into a
|
||||
// stored N — the cleared-port bug, #6121. Handlers here go through
|
||||
// onNumber() (src/utils/onNumber.ts) instead. Known limit: a handler
|
||||
// extracted into a variable and passed as onChange={handler} is not
|
||||
// matched; the inline shapes below are the ones that drift in practice.
|
||||
files: ['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': ['error', {
|
||||
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="||"] > CallExpression[callee.name="Number"]',
|
||||
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||
}, {
|
||||
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] ConditionalExpression[test.left.operator="typeof"][alternate.type="Literal"]',
|
||||
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||
}, {
|
||||
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="??"][right.type="Literal"]',
|
||||
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
// The xray form modals (OutboundFormModal, BalancerFormModal,
|
||||
// DnsServerModal, WarpModal, …) stage values behind Zod validation like
|
||||
// the clients/inbounds modals do, and some of their fields carry a
|
||||
// deliberate clear-means-zero semantic — the direct-write rule above
|
||||
// does not apply to them.
|
||||
files: ['src/pages/xray/**/*Modal.tsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', '../internal/web/dist/**', 'src/generated/**'] },
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint.plugin,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-deprecated': 'warn',
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
Generated
+1508
-2851
File diff suppressed because it is too large
Load Diff
+25
-24
@@ -12,7 +12,11 @@
|
||||
"dev": "vite",
|
||||
"build": "npm run gen:api && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"lint": "oxlint src tools",
|
||||
"lint:fix": "oxlint --fix src tools",
|
||||
"lint:deprecated": "oxlint --type-aware -A all -D typescript/no-deprecated src",
|
||||
"format": "oxfmt src tools",
|
||||
"format:check": "oxfmt --check src tools",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -24,19 +28,22 @@
|
||||
"prepare": "cd .. && husky frontend/.husky || true"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,tsx}": "eslint --fix"
|
||||
"src/**/*.{ts,tsx}": [
|
||||
"oxfmt",
|
||||
"oxlint --fix"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^5.7.1",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"antd": "^6.6.0",
|
||||
"antd": "^6.6.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.23",
|
||||
"i18next": "^26.3.6",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.6",
|
||||
@@ -45,43 +52,37 @@
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router": "^8.3.0",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"swagger-ui-react": "^5.32.14",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@storybook/addon-a11y": "^10.5.7",
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@storybook/addon-vitest": "^10.5.7",
|
||||
"@storybook/react-vite": "^10.5.7",
|
||||
"@storybook/addon-a11y": "^10.5.9",
|
||||
"@storybook/addon-docs": "^10.5.9",
|
||||
"@storybook/addon-vitest": "^10.5.9",
|
||||
"@storybook/react-vite": "^10.5.9",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.11.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.3.0",
|
||||
"msw": "^2.15.0",
|
||||
"oxfmt": "0.64.0",
|
||||
"oxlint": "1.79.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"playwright": "^1.62.1",
|
||||
"storybook": "^10.5.7",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"storybook": "^10.5.9",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"overrides": {
|
||||
"eslint-plugin-jsx-a11y": {
|
||||
"eslint": "$eslint"
|
||||
},
|
||||
"dompurify": "^3.4.11",
|
||||
"react-copy-to-clipboard": "^5.1.1",
|
||||
"react-inspector": "^9.0.0",
|
||||
|
||||
@@ -79,7 +79,9 @@ function encodeForm(data: unknown): string {
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => append(`${key}[${k}]`, v));
|
||||
Object.entries(value as Record<string, unknown>).forEach(([k, v]) =>
|
||||
append(`${key}[${k}]`, v),
|
||||
);
|
||||
return;
|
||||
}
|
||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { AllSettingResponseSchema, AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||
import {
|
||||
AllSettingResponseSchema,
|
||||
AllSettingSchema,
|
||||
type AllSettingInput,
|
||||
} from '@/schemas/setting';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||
|
||||
@@ -39,22 +43,34 @@ export function useAllSettings() {
|
||||
);
|
||||
const allSetting = draft ?? server;
|
||||
|
||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||
setDraft((prev) => {
|
||||
const next = new AllSetting(prev ?? server);
|
||||
Object.assign(next, patch);
|
||||
return next;
|
||||
});
|
||||
}, [server, setDraft]);
|
||||
const updateSetting = useCallback(
|
||||
(patch: Partial<AllSetting>) => {
|
||||
setDraft((prev) => {
|
||||
const next = new AllSetting(prev ?? server);
|
||||
Object.assign(next, patch);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[server, setDraft],
|
||||
);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
|
||||
mutationFn: async ({
|
||||
payload,
|
||||
saved,
|
||||
}: {
|
||||
payload: SettingSavePayload;
|
||||
saved?: AllSetting;
|
||||
}): Promise<SettingSaveResult> => {
|
||||
const next = { ...payload };
|
||||
const body = AllSettingSchema.partial().safeParse(next);
|
||||
if (!body.success) {
|
||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||
}
|
||||
const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/setting/update',
|
||||
body.success ? { ...next, ...body.data } : next,
|
||||
);
|
||||
return { msg, saved };
|
||||
},
|
||||
onSuccess: ({ msg, saved }) => {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
async function fetchFactoryDefaults(): Promise<FactoryDefaults> {
|
||||
const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true });
|
||||
const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, {
|
||||
silent: true,
|
||||
});
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults');
|
||||
const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults');
|
||||
const parsed = FactoryDefaultsSchema.safeParse(validated.obj);
|
||||
|
||||
@@ -18,7 +18,9 @@ const FAIL_OPEN_STATUS: Fail2banStatus = {
|
||||
};
|
||||
|
||||
async function fetchFail2banStatus(): Promise<Fail2banStatus> {
|
||||
const msg = await HttpUtil.get<Fail2banStatus>('/panel/api/server/fail2banStatus', undefined, { silent: true });
|
||||
const msg = await HttpUtil.get<Fail2banStatus>('/panel/api/server/fail2banStatus', undefined, {
|
||||
silent: true,
|
||||
});
|
||||
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch fail2ban status');
|
||||
return { ...FAIL_OPEN_STATUS, ...msg.obj };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { GeoCategoryPageSchema, GeoEntryPageSchema, GeoFileSchema, GeodataTokenIssueSchema } from '@/generated/zod';
|
||||
import {
|
||||
GeoCategoryPageSchema,
|
||||
GeoEntryPageSchema,
|
||||
GeoFileSchema,
|
||||
GeodataTokenIssueSchema,
|
||||
} from '@/generated/zod';
|
||||
import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
@@ -28,7 +33,11 @@ async function fetchGeodataFiles(): Promise<GeoFile[]> {
|
||||
}
|
||||
|
||||
async function fetchGeodataCategories(file: string, query: string): Promise<GeoCategoryPage> {
|
||||
const msg = await HttpUtil.get('/panel/api/xray/geodata/categories', { file, q: query }, { silent: true });
|
||||
const msg = await HttpUtil.get(
|
||||
'/panel/api/xray/geodata/categories',
|
||||
{ file, q: query },
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
|
||||
const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories');
|
||||
return validated.obj ?? EMPTY_CATEGORY_PAGE;
|
||||
|
||||
@@ -11,50 +11,69 @@ export function useHostMutations() {
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() });
|
||||
|
||||
const bulkCreateMut = useMutation({
|
||||
mutationFn: (payload: BulkAddHostValues) => HttpUtil.post('/panel/api/hosts/bulk/add', payload, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
mutationFn: (payload: BulkAddHostValues) =>
|
||||
HttpUtil.post('/panel/api/hosts/bulk/add', payload, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) =>
|
||||
HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const setEnableMut = useMutation({
|
||||
mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) =>
|
||||
HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const reorderMut = useMutation({
|
||||
mutationFn: (groupIds: string[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
mutationFn: (groupIds: string[]) =>
|
||||
HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkEnableMut = useMutation({
|
||||
mutationFn: ({ groupIds, enable }: { groupIds: string[]; enable: boolean }) =>
|
||||
HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids: groupIds, enable }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkDelMut = useMutation({
|
||||
mutationFn: (groupIds: string[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids: groupIds }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
mutationFn: (groupIds: string[]) =>
|
||||
HttpUtil.post('/panel/api/hosts/bulk/del', { ids: groupIds }, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
bulkCreate: (payload: BulkAddHostValues) => bulkCreateMut.mutateAsync(payload),
|
||||
update: (groupId: string, payload: BulkAddHostValues) => updateMut.mutateAsync({ groupId, payload }),
|
||||
update: (groupId: string, payload: BulkAddHostValues) =>
|
||||
updateMut.mutateAsync({ groupId, payload }),
|
||||
remove: (groupId: string) => removeMut.mutateAsync(groupId),
|
||||
setEnable: (groupId: string, enable: boolean) => setEnableMut.mutateAsync({ groupId, enable }),
|
||||
reorder: (groupIds: string[]) => reorderMut.mutateAsync(groupIds),
|
||||
bulkSetEnable: (groupIds: string[], enable: boolean) => bulkEnableMut.mutateAsync({ groupIds, enable }),
|
||||
bulkSetEnable: (groupIds: string[], enable: boolean) =>
|
||||
bulkEnableMut.mutateAsync({ groupIds, enable }),
|
||||
bulkDel: (groupIds: string[]) => bulkDelMut.mutateAsync(groupIds),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,27 +30,33 @@ export function useNodeMutations() {
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: Partial<NodeRecord>) =>
|
||||
HttpUtil.post('/panel/api/nodes/add', payload),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
mutationFn: (payload: Partial<NodeRecord>) => HttpUtil.post('/panel/api/nodes/add', payload),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) =>
|
||||
HttpUtil.post(`/panel/api/nodes/update/${id}`, payload),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
HttpUtil.post(`/panel/api/nodes/del/${id}`),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
mutationFn: (id: number) => HttpUtil.post(`/panel/api/nodes/del/${id}`),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const setEnableMut = useMutation({
|
||||
mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
|
||||
HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const probeMut = useMutation({
|
||||
@@ -58,15 +64,23 @@ export function useNodeMutations() {
|
||||
const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/probe');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updatePanelsMut = useMutation({
|
||||
mutationFn: ({ ids, dev }: { ids: number[]; dev: boolean }) =>
|
||||
HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids, dev }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
HttpUtil.post<NodeUpdateResult[]>(
|
||||
'/panel/api/nodes/updatePanel',
|
||||
{ ids, dev },
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -75,7 +89,8 @@ export function useNodeMutations() {
|
||||
remove: (id: number) => removeMut.mutateAsync(id),
|
||||
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
|
||||
probe: (id: number) => probeMut.mutateAsync(id),
|
||||
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync({ ids, dev }),
|
||||
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> =>
|
||||
updatePanelsMut.mutateAsync({ ids, dev }),
|
||||
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
||||
|
||||
@@ -26,7 +26,9 @@ export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
|
||||
}
|
||||
// Balancers are valid routing targets too — injectMtprotoEgress emits a
|
||||
// balancerTag rule when the chosen tag names a balancer.
|
||||
const balancers = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
|
||||
const balancers = (
|
||||
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
|
||||
)?.balancers;
|
||||
for (const b of balancers ?? []) {
|
||||
if (b?.tag) tags.add(b.tag);
|
||||
}
|
||||
@@ -61,7 +63,9 @@ export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
|
||||
if (t) outbounds.add(t);
|
||||
}
|
||||
const balancers: string[] = [];
|
||||
const bal = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
|
||||
const bal = (
|
||||
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
|
||||
)?.balancers;
|
||||
for (const b of bal ?? []) {
|
||||
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ export function useStatusQuery() {
|
||||
});
|
||||
|
||||
const status = useMemo(() => query.data ?? new Status(), [query.data]);
|
||||
const refresh = async () => { await query.refetch(); };
|
||||
const refresh = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
@@ -41,7 +41,8 @@ export const keys = {
|
||||
geodata: {
|
||||
root: () => ['xray', 'geodata'] as const,
|
||||
files: () => ['xray', 'geodata', 'files'] as const,
|
||||
categories: (file: string, query: string) => ['xray', 'geodata', 'categories', file, query] as const,
|
||||
categories: (file: string, query: string) =>
|
||||
['xray', 'geodata', 'categories', file, query] as const,
|
||||
entries: (file: string, code: string, query: string, offset: number, limit: number) =>
|
||||
['xray', 'geodata', 'entries', file, code, query, offset, limit] as const,
|
||||
},
|
||||
|
||||
@@ -35,7 +35,10 @@ export class WebSocketClient {
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.shouldReconnect = true;
|
||||
@@ -48,7 +51,9 @@ export class WebSocketClient {
|
||||
this.#cancelReconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.ws) {
|
||||
try { this.ws.close(1000, 'client disconnect'); } catch {}
|
||||
try {
|
||||
this.ws.close(1000, 'client disconnect');
|
||||
} catch {}
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
@@ -130,7 +135,9 @@ export class WebSocketClient {
|
||||
const byteLen = new Blob([data]).size;
|
||||
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
||||
try { this.ws?.close(1009, 'message too big'); } catch {}
|
||||
try {
|
||||
this.ws?.close(1009, 'message too big');
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -141,7 +148,11 @@ export class WebSocketClient {
|
||||
console.error('WebSocket: invalid JSON message', err);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || typeof (message as { type?: unknown }).type !== 'string') {
|
||||
if (
|
||||
!message ||
|
||||
typeof message !== 'object' ||
|
||||
typeof (message as { type?: unknown }).type !== 'string'
|
||||
) {
|
||||
console.error('WebSocket: malformed message envelope');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ type ClientCardCommentProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function ClientCardComment({ comment, className = 'client-card-comment' }: ClientCardCommentProps) {
|
||||
export default function ClientCardComment({
|
||||
comment,
|
||||
className = 'client-card-comment',
|
||||
}: ClientCardCommentProps) {
|
||||
if (!comment) return null;
|
||||
|
||||
return (
|
||||
@@ -11,4 +14,4 @@ export default function ClientCardComment({ comment, className = 'client-card-co
|
||||
{comment}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps
|
||||
style={tableCell ? SPEED_TAG_STYLE : undefined}
|
||||
>
|
||||
↑ {SizeFormatter.speedFormat(speed.up)}
|
||||
{' / '}
|
||||
↓ {SizeFormatter.speedFormat(speed.down)}
|
||||
{' / '}↓ {SizeFormatter.speedFormat(speed.down)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ const meta = {
|
||||
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.' },
|
||||
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>;
|
||||
|
||||
@@ -60,7 +60,9 @@ const ClientTrafficCell = memo(function ClientTrafficCell({
|
||||
'client-traffic-cell',
|
||||
compact ? 'is-compact' : '',
|
||||
display.isUnlimited ? 'is-unlimited' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<Popover content={popover} trigger={['hover', 'click']} placement="top">
|
||||
@@ -77,7 +79,11 @@ const ClientTrafficCell = memo(function ClientTrafficCell({
|
||||
/>
|
||||
<span className="client-traffic-cell-limit">
|
||||
{display.isUnlimited ? (
|
||||
<span className="client-traffic-cell-infinity" role="img" aria-label={t('subscription.unlimited')}>
|
||||
<span
|
||||
className="client-traffic-cell-infinity"
|
||||
role="img"
|
||||
aria-label={t('subscription.unlimited')}
|
||||
>
|
||||
<InfinityIcon />
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -17,8 +17,13 @@ const meta = {
|
||||
},
|
||||
},
|
||||
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.' },
|
||||
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.' },
|
||||
@@ -31,8 +36,9 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const sampleLink = 'vless://11112222-3333-4444-5555-666677778888@panel.example.com:443'
|
||||
+ '?type=ws&security=tls&path=%2Fpath#example-node';
|
||||
const sampleLink =
|
||||
'vless://11112222-3333-4444-5555-666677778888@panel.example.com:443' +
|
||||
'?type=ws&security=tls&path=%2Fpath#example-node';
|
||||
|
||||
export const Collapsed: Story = {
|
||||
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
|
||||
@@ -58,5 +64,11 @@ export const Expanded: Story = {
|
||||
};
|
||||
|
||||
export const WithoutQr: Story = {
|
||||
args: { label: 'trojan', text: sampleLink, fileName: 'client-config.txt', showQr: false, tagColor: 'geekblue' },
|
||||
args: {
|
||||
label: 'trojan',
|
||||
text: sampleLink,
|
||||
fileName: 'client-config.txt',
|
||||
showQr: false,
|
||||
tagColor: 'geekblue',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,12 +70,18 @@ export default function ConfigBlock({
|
||||
className="config-block"
|
||||
collapsible="header"
|
||||
defaultActiveKey={defaultOpen ? ['cfg'] : []}
|
||||
items={[{
|
||||
key: 'cfg',
|
||||
label: <Tag color={tagColor} style={{ margin: 0, fontWeight: 600, letterSpacing: '0.3px' }}>{label}</Tag>,
|
||||
extra: actions,
|
||||
children: <code className="config-block-text">{text}</code>,
|
||||
}]}
|
||||
items={[
|
||||
{
|
||||
key: 'cfg',
|
||||
label: (
|
||||
<Tag color={tagColor} style={{ margin: 0, fontWeight: 600, letterSpacing: '0.3px' }}>
|
||||
{label}
|
||||
</Tag>
|
||||
),
|
||||
extra: actions,
|
||||
children: <code className="config-block-text">{text}</code>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,9 @@ function InputDemo() {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" onClick={() => setOpen(true)}>Rename client</Button>
|
||||
<Button type="primary" onClick={() => setOpen(true)}>
|
||||
Rename client
|
||||
</Button>
|
||||
<div style={{ marginTop: 12 }}>Last confirmed: {value || '—'}</div>
|
||||
<PromptModal
|
||||
open={open}
|
||||
|
||||
@@ -33,15 +33,21 @@ export default function PromptModal({
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
|
||||
const [openedWith, setOpenedWith] = useState<string | null>(null);
|
||||
const openKey = open ? `${type}\u0000${initialValue}` : null;
|
||||
if (openKey !== openedWith) {
|
||||
setOpenedWith(openKey);
|
||||
if (open) setValue(initialValue);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(initialValue);
|
||||
setTimeout(() => {
|
||||
if (type === 'textarea') textareaRef.current?.focus();
|
||||
else inputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [open, initialValue, type]);
|
||||
if (!open) return;
|
||||
const id = setTimeout(() => {
|
||||
if (type === 'textarea') textareaRef.current?.focus();
|
||||
else inputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(id);
|
||||
}, [open, type]);
|
||||
|
||||
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
|
||||
if (type !== 'textarea' && e.key === 'Enter') {
|
||||
@@ -71,7 +77,11 @@ export default function PromptModal({
|
||||
<JsonEditor value={value} onChange={setValue} minHeight="240px" maxHeight="60vh" />
|
||||
) : type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
||||
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)}
|
||||
|
||||
@@ -21,8 +21,13 @@ const meta = {
|
||||
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.' },
|
||||
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.' },
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button, Input, Modal, Tabs, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -22,14 +22,25 @@ interface TextModalProps {
|
||||
tabs?: TextModalTab[];
|
||||
}
|
||||
|
||||
export default function TextModal({ open, onClose, title, content, fileName = '', json = false, tabs }: TextModalProps) {
|
||||
export default function TextModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
content,
|
||||
fileName = '',
|
||||
json = false,
|
||||
tabs,
|
||||
}: TextModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [activeKey, setActiveKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open && tabs && tabs.length > 0) setActiveKey(tabs[0].key);
|
||||
}, [open, tabs]);
|
||||
// Reset on the way out so the next open starts on the first tab; activeTab
|
||||
// falls back to tabs[0] whenever activeKey no longer matches.
|
||||
const close = useCallback(() => {
|
||||
setActiveKey('');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const activeTab = tabs?.find((tab) => tab.key === activeKey) ?? tabs?.[0];
|
||||
const activeContent = activeTab ? activeTab.content : content;
|
||||
@@ -38,7 +49,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||
const ok = await ClipboardManager.copyText(activeContent || '');
|
||||
if (ok) {
|
||||
messageApi.success(t('copied'));
|
||||
onClose();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,39 +64,43 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onCancel={onClose}
|
||||
onCancel={close}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<>
|
||||
{fileName && (
|
||||
<Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
|
||||
)}
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>{t('copy')}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<Tabs
|
||||
activeKey={activeTab?.key}
|
||||
onChange={setActiveKey}
|
||||
items={tabs.map((tab) => ({ key: tab.key, label: tab.label }))}
|
||||
/>
|
||||
)}
|
||||
{json ? (
|
||||
<JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
|
||||
) : (
|
||||
<Input.TextArea
|
||||
aria-label={title}
|
||||
value={activeContent}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
footer={
|
||||
<>
|
||||
{fileName && (
|
||||
<Button icon={<DownloadOutlined />} onClick={download}>
|
||||
{fileName}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>
|
||||
{t('copy')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<Tabs
|
||||
activeKey={activeTab?.key}
|
||||
onChange={setActiveKey}
|
||||
items={tabs.map((tab) => ({ key: tab.key, label: tab.label }))}
|
||||
/>
|
||||
)}
|
||||
{json ? (
|
||||
<JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
|
||||
) : (
|
||||
<Input.TextArea
|
||||
aria-label={title}
|
||||
value={activeContent}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
.jdp-dark input::placeholder,
|
||||
.jdp-ultra input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.30) !important;
|
||||
color: rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.jdp-disabled {
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
|
||||
.jdp-dark .jdp-clear {
|
||||
color: rgba(255, 255, 255, 0.30);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.jdp-dark .jdp-clear:hover,
|
||||
|
||||
@@ -17,7 +17,9 @@ function ClientExpiryDemo() {
|
||||
<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)'}
|
||||
{value
|
||||
? `user1@node-de expiryTime: ${value.valueOf()}`
|
||||
: 'user1@node-de expiryTime: 0 (never expires)'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -90,7 +90,10 @@ export default function DateTimePicker({
|
||||
|
||||
if (datepicker === 'jalalian') {
|
||||
return (
|
||||
<div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}${value ? '' : ' jdp-empty'}`}>
|
||||
<div
|
||||
ref={jalaliRef}
|
||||
className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}${value ? '' : ' jdp-empty'}`}
|
||||
>
|
||||
<PersianDateTimePicker
|
||||
key={clearNonce}
|
||||
value={value ? value.valueOf() : null}
|
||||
|
||||
@@ -17,9 +17,17 @@ const meta = {
|
||||
},
|
||||
},
|
||||
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.' },
|
||||
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>;
|
||||
|
||||
@@ -63,7 +71,14 @@ function WireShapeDemo() {
|
||||
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)' }}>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: 'rgba(128, 128, 128, 0.12)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(value ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -24,10 +24,7 @@ import { InputAddon } from '@/components/ui';
|
||||
|
||||
export type HeaderMapMode = 'v1' | 'v2';
|
||||
|
||||
export type HeaderMapValue =
|
||||
| Record<string, string>
|
||||
| Record<string, string[]>
|
||||
| undefined;
|
||||
export type HeaderMapValue = Record<string, string> | Record<string, string[]> | undefined;
|
||||
|
||||
interface HeaderRow {
|
||||
name: string;
|
||||
@@ -55,7 +52,10 @@ function mapToRows(value: HeaderMapValue): HeaderRow[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, string> | Record<string, string[]> {
|
||||
function rowsToMap(
|
||||
rows: HeaderRow[],
|
||||
mode: HeaderMapMode,
|
||||
): Record<string, string> | Record<string, string[]> {
|
||||
if (mode === 'v1') {
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) {
|
||||
@@ -132,7 +132,11 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit
|
||||
placeholder="Value"
|
||||
onChange={(e) => setRow(idx, { value: e.target.value })}
|
||||
/>
|
||||
<Button aria-label={t('remove')} icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
|
||||
<Button
|
||||
aria-label={t('remove')}
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() => removeRow(idx)}
|
||||
/>
|
||||
</Space.Compact>
|
||||
))}
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
|
||||
|
||||
@@ -45,8 +45,9 @@ function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalett
|
||||
},
|
||||
'.cm-activeLine': { backgroundColor: activeBg },
|
||||
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{ backgroundColor: selection },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: selection,
|
||||
},
|
||||
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||
|
||||
@@ -17,7 +17,10 @@ const meta = {
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
value: { description: 'Current template string; any {{VAR}} token enables the live preview below the input.' },
|
||||
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.' },
|
||||
@@ -30,7 +33,14 @@ 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}}" />;
|
||||
return (
|
||||
<RemarkTemplateField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
maxLength={256}
|
||||
placeholder="{{INBOUND}}-{{EMAIL}}"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
|
||||
@@ -5,7 +5,12 @@ import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import { CodeOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { hasRemarkTokens, previewRemark, SUBSCRIPTION_METADATA_VARIABLES, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import {
|
||||
hasRemarkTokens,
|
||||
previewRemark,
|
||||
SUBSCRIPTION_METADATA_VARIABLES,
|
||||
wrapToken,
|
||||
} from '@/lib/remark/remarkVariables';
|
||||
import RemarkVarPicker from './RemarkVarPicker';
|
||||
|
||||
interface RemarkTemplateFieldProps {
|
||||
@@ -24,7 +29,15 @@ interface RemarkTemplateFieldProps {
|
||||
* (insert-at-caret) and a live, sample-based preview of the expanded result.
|
||||
* Used for subscription text fields that support Remark Template variables.
|
||||
*/
|
||||
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder, multiline = false, rows, metadataOnly = false }: RemarkTemplateFieldProps) {
|
||||
export default function RemarkTemplateField({
|
||||
value = '',
|
||||
onChange,
|
||||
maxLength,
|
||||
placeholder,
|
||||
multiline = false,
|
||||
rows,
|
||||
metadataOnly = false,
|
||||
}: RemarkTemplateFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const textAreaRef = useRef<TextAreaRef>(null);
|
||||
@@ -60,7 +73,13 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
|
||||
title={t('pages.hosts.remarkVars.title')}
|
||||
>
|
||||
<Tooltip title={t('pages.hosts.remarkVars.title')}>
|
||||
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CodeOutlined />}
|
||||
aria-label={t('pages.hosts.remarkVars.title')}
|
||||
style={{ marginInlineEnd: -7 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
@@ -92,7 +111,9 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
|
||||
{hasRemarkTokens(value) && (
|
||||
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
|
||||
{t('pages.hosts.remarkVars.preview')}:{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value, variables, metadataOnly) || '—'}</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>
|
||||
{previewRemark(value, variables, metadataOnly) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,10 @@ const meta = {
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
onPick: { description: 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.' },
|
||||
onPick: {
|
||||
description:
|
||||
'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RemarkVarPicker>;
|
||||
|
||||
@@ -29,7 +32,9 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function TemplateBuilderDemo() {
|
||||
const [template, setTemplate] = useState('{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left');
|
||||
const [template, setTemplate] = useState(
|
||||
'{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left',
|
||||
);
|
||||
return (
|
||||
<div style={{ maxWidth: 520 }}>
|
||||
<Input
|
||||
|
||||
@@ -15,35 +15,50 @@ interface RemarkVarPickerProps {
|
||||
* RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
|
||||
* the global remark-template field.
|
||||
*/
|
||||
export default function RemarkVarPicker({ onPick, variables = REMARK_VARIABLES }: RemarkVarPickerProps) {
|
||||
export default function RemarkVarPicker({
|
||||
onPick,
|
||||
variables = REMARK_VARIABLES,
|
||||
}: RemarkVarPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||
{t('pages.hosts.remarkVars.intro')}
|
||||
</Typography.Paragraph>
|
||||
{REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => (
|
||||
<div key={group} style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
|
||||
{t(`pages.hosts.remarkVars.groups.${group}`)}
|
||||
{REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map(
|
||||
(group) => (
|
||||
<div key={group} style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.6,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{t(`pages.hosts.remarkVars.groups.${group}`)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{variables
|
||||
.filter((v) => v.group === group)
|
||||
.map((v) => (
|
||||
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
|
||||
<Tag
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onPick(v.token)}
|
||||
onKeyDown={activateOnKey(() => onPick(v.token))}
|
||||
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
|
||||
>
|
||||
{wrapToken(v.token)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{variables.filter((v) => v.group === group).map((v) => (
|
||||
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
|
||||
<Tag
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onPick(v.token)}
|
||||
onKeyDown={activateOnKey(() => onPick(v.token))}
|
||||
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
|
||||
>
|
||||
{wrapToken(v.token)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,11 +33,23 @@ const meta = {
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
options: { description: 'Option list whose values define the "all" set; matches the AntD Select option shape.' },
|
||||
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.' },
|
||||
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>;
|
||||
|
||||
|
||||
@@ -35,11 +35,7 @@ export default function SelectAllClearButtons<T extends string | number = number
|
||||
>
|
||||
{selectAllLabel ?? t('pages.clients.selectAllInbounds')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={value.length === 0}
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
<Button size="small" disabled={value.length === 0} onClick={() => onChange([])}>
|
||||
{clearLabel ?? t('pages.clients.clearAllInbounds')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -25,14 +25,24 @@ const meta = {
|
||||
},
|
||||
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.' },
|
||||
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.' },
|
||||
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.' },
|
||||
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.' },
|
||||
@@ -56,7 +66,12 @@ function ClientDemo() {
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
||||
<FormField name="email" label="Email" tooltip="Unique identifier used to match client traffic" required>
|
||||
<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">
|
||||
@@ -96,7 +111,9 @@ function TrafficDemo() {
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Typography.Text type="secondary">Form state: {totalBytes.toLocaleString()} bytes</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
Form state: {totalBytes.toLocaleString()} bytes
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,9 @@ export function useZodForm<TFieldValues extends FieldValues>(
|
||||
schema: z.ZodType<TFieldValues>,
|
||||
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
|
||||
): UseFormReturn<TFieldValues> {
|
||||
const resolver = zodResolver(schema as z.ZodType<TFieldValues, TFieldValues>) as Resolver<TFieldValues>;
|
||||
const resolver = zodResolver(
|
||||
schema as z.ZodType<TFieldValues, TFieldValues>,
|
||||
) as Resolver<TFieldValues>;
|
||||
return useForm<TFieldValues>({
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
|
||||
@@ -42,7 +42,9 @@ function deactivate(routes: GeoRoutes): void {
|
||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
||||
const [client] = useState(() => {
|
||||
activate(routes);
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
});
|
||||
useEffect(() => {
|
||||
activate(routes);
|
||||
@@ -61,121 +63,379 @@ const cross = (names: string[], suffixes: string[]): GeoEntry[] =>
|
||||
names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`)));
|
||||
|
||||
const CC_TLDS = [
|
||||
'ae', 'al', 'am', 'at', 'az', 'ba', 'be', 'bg', 'bi', 'bj', 'ca', 'cat', 'cd', 'cf', 'cg', 'ch',
|
||||
'ci', 'cl', 'cm', 'co.id', 'co.il', 'co.in', 'co.jp', 'co.ke', 'co.kr', 'co.ma', 'co.nz', 'co.th',
|
||||
'co.uk', 'co.uz', 'co.ve', 'co.za', 'com.ar', 'com.au', 'com.bd', 'com.br', 'com.co', 'com.cu',
|
||||
'com.eg', 'com.gt', 'com.hk', 'com.mx', 'com.my', 'com.ng', 'com.pe', 'com.ph', 'com.pk',
|
||||
'com.sa', 'com.sg', 'com.tr', 'com.tw', 'com.ua', 'com.uy', 'com.vn', 'cz', 'de', 'dj', 'dk',
|
||||
'dz', 'ee', 'es', 'fi', 'fr', 'ga', 'ge', 'gl', 'gm', 'gr', 'hn', 'hr', 'ht', 'hu', 'ie', 'iq',
|
||||
'is', 'it', 'je', 'jo', 'kg', 'kz', 'la', 'li', 'lk', 'lt', 'lu', 'lv', 'ly', 'md', 'me', 'mg',
|
||||
'mk', 'ml', 'mn', 'mu', 'mv', 'mw', 'ne', 'nl', 'no', 'nu', 'pl', 'pt', 'ro', 'rs', 'ru', 'rw',
|
||||
'se', 'sh', 'si', 'sk', 'sm', 'sn', 'so', 'sr', 'st', 'td', 'tg', 'tk', 'tl', 'tm', 'tn', 'to',
|
||||
'tt', 'vg', 'vu', 'ws',
|
||||
'ae',
|
||||
'al',
|
||||
'am',
|
||||
'at',
|
||||
'az',
|
||||
'ba',
|
||||
'be',
|
||||
'bg',
|
||||
'bi',
|
||||
'bj',
|
||||
'ca',
|
||||
'cat',
|
||||
'cd',
|
||||
'cf',
|
||||
'cg',
|
||||
'ch',
|
||||
'ci',
|
||||
'cl',
|
||||
'cm',
|
||||
'co.id',
|
||||
'co.il',
|
||||
'co.in',
|
||||
'co.jp',
|
||||
'co.ke',
|
||||
'co.kr',
|
||||
'co.ma',
|
||||
'co.nz',
|
||||
'co.th',
|
||||
'co.uk',
|
||||
'co.uz',
|
||||
'co.ve',
|
||||
'co.za',
|
||||
'com.ar',
|
||||
'com.au',
|
||||
'com.bd',
|
||||
'com.br',
|
||||
'com.co',
|
||||
'com.cu',
|
||||
'com.eg',
|
||||
'com.gt',
|
||||
'com.hk',
|
||||
'com.mx',
|
||||
'com.my',
|
||||
'com.ng',
|
||||
'com.pe',
|
||||
'com.ph',
|
||||
'com.pk',
|
||||
'com.sa',
|
||||
'com.sg',
|
||||
'com.tr',
|
||||
'com.tw',
|
||||
'com.ua',
|
||||
'com.uy',
|
||||
'com.vn',
|
||||
'cz',
|
||||
'de',
|
||||
'dj',
|
||||
'dk',
|
||||
'dz',
|
||||
'ee',
|
||||
'es',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'ge',
|
||||
'gl',
|
||||
'gm',
|
||||
'gr',
|
||||
'hn',
|
||||
'hr',
|
||||
'ht',
|
||||
'hu',
|
||||
'ie',
|
||||
'iq',
|
||||
'is',
|
||||
'it',
|
||||
'je',
|
||||
'jo',
|
||||
'kg',
|
||||
'kz',
|
||||
'la',
|
||||
'li',
|
||||
'lk',
|
||||
'lt',
|
||||
'lu',
|
||||
'lv',
|
||||
'ly',
|
||||
'md',
|
||||
'me',
|
||||
'mg',
|
||||
'mk',
|
||||
'ml',
|
||||
'mn',
|
||||
'mu',
|
||||
'mv',
|
||||
'mw',
|
||||
'ne',
|
||||
'nl',
|
||||
'no',
|
||||
'nu',
|
||||
'pl',
|
||||
'pt',
|
||||
'ro',
|
||||
'rs',
|
||||
'ru',
|
||||
'rw',
|
||||
'se',
|
||||
'sh',
|
||||
'si',
|
||||
'sk',
|
||||
'sm',
|
||||
'sn',
|
||||
'so',
|
||||
'sr',
|
||||
'st',
|
||||
'td',
|
||||
'tg',
|
||||
'tk',
|
||||
'tl',
|
||||
'tm',
|
||||
'tn',
|
||||
'to',
|
||||
'tt',
|
||||
'vg',
|
||||
'vu',
|
||||
'ws',
|
||||
];
|
||||
|
||||
const AD_HOSTS = [
|
||||
'adform', 'adnxs', 'adroll', 'adsrvr', 'amplitude', 'appsflyer', 'bluekai', 'branch',
|
||||
'casalemedia', 'criteo', 'flurry', 'moatads', 'mopub', 'openx', 'outbrain', 'pubmatic',
|
||||
'quantserve', 'rubiconproject', 'scorecardresearch', 'sharethrough', 'smartadserver', 'taboola',
|
||||
'teads', 'yieldmo', 'zemanta',
|
||||
'adform',
|
||||
'adnxs',
|
||||
'adroll',
|
||||
'adsrvr',
|
||||
'amplitude',
|
||||
'appsflyer',
|
||||
'bluekai',
|
||||
'branch',
|
||||
'casalemedia',
|
||||
'criteo',
|
||||
'flurry',
|
||||
'moatads',
|
||||
'mopub',
|
||||
'openx',
|
||||
'outbrain',
|
||||
'pubmatic',
|
||||
'quantserve',
|
||||
'rubiconproject',
|
||||
'scorecardresearch',
|
||||
'sharethrough',
|
||||
'smartadserver',
|
||||
'taboola',
|
||||
'teads',
|
||||
'yieldmo',
|
||||
'zemanta',
|
||||
];
|
||||
|
||||
const CN_BRANDS = [
|
||||
'58', 'alibaba', 'alipay', 'aliyun', 'baidu', 'bilibili', 'cnblogs', 'csdn', 'ctrip', 'douban',
|
||||
'gitee', 'huawei', 'iqiyi', 'jd', 'kuaishou', 'meituan', 'netease', 'pinduoduo', 'qq', 'sina',
|
||||
'sohu', 'taobao', 'tencent', 'tmall', 'toutiao', 'weibo', 'xiaomi', 'youku', 'zhihu',
|
||||
'58',
|
||||
'alibaba',
|
||||
'alipay',
|
||||
'aliyun',
|
||||
'baidu',
|
||||
'bilibili',
|
||||
'cnblogs',
|
||||
'csdn',
|
||||
'ctrip',
|
||||
'douban',
|
||||
'gitee',
|
||||
'huawei',
|
||||
'iqiyi',
|
||||
'jd',
|
||||
'kuaishou',
|
||||
'meituan',
|
||||
'netease',
|
||||
'pinduoduo',
|
||||
'qq',
|
||||
'sina',
|
||||
'sohu',
|
||||
'taobao',
|
||||
'tencent',
|
||||
'tmall',
|
||||
'toutiao',
|
||||
'weibo',
|
||||
'xiaomi',
|
||||
'youku',
|
||||
'zhihu',
|
||||
];
|
||||
|
||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
amazon: [
|
||||
domain('amazon.com'), domain('amazonaws.com'), domain('media-amazon.com'),
|
||||
domain('ssl-images-amazon.com'), domain('primevideo.com'), domain('awsstatic.com'),
|
||||
domain('cloudfront.net'), full('www.amazon.co.jp'),
|
||||
domain('amazon.com'),
|
||||
domain('amazonaws.com'),
|
||||
domain('media-amazon.com'),
|
||||
domain('ssl-images-amazon.com'),
|
||||
domain('primevideo.com'),
|
||||
domain('awsstatic.com'),
|
||||
domain('cloudfront.net'),
|
||||
full('www.amazon.co.jp'),
|
||||
],
|
||||
apple: [
|
||||
domain('apple.com'), domain('icloud.com'), domain('cdn-apple.com'), domain('mzstatic.com'),
|
||||
domain('apple-cloudkit.com'), domain('itunes.com'), domain('me.com'), domain('appstore.com'),
|
||||
domain('apple.com'),
|
||||
domain('icloud.com'),
|
||||
domain('cdn-apple.com'),
|
||||
domain('mzstatic.com'),
|
||||
domain('apple-cloudkit.com'),
|
||||
domain('itunes.com'),
|
||||
domain('me.com'),
|
||||
domain('appstore.com'),
|
||||
],
|
||||
'category-ads': [
|
||||
domain('adcolony.com'), domain('applovin.com'), domain('chartboost.com'),
|
||||
domain('inmobi.com'), domain('unityads.unity3d.com'), keyword('banner-ad'),
|
||||
domain('adcolony.com'),
|
||||
domain('applovin.com'),
|
||||
domain('chartboost.com'),
|
||||
domain('inmobi.com'),
|
||||
domain('unityads.unity3d.com'),
|
||||
keyword('banner-ad'),
|
||||
],
|
||||
'category-ads-all': [
|
||||
domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
|
||||
domain('adservice.google.com'), full('ads.yahoo.com'), keyword('adservice'),
|
||||
keyword('advertising'), regexp('^ad[0-9]{1,3}\\.'), ...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
|
||||
domain('doubleclick.net'),
|
||||
domain('googleadservices.com'),
|
||||
domain('googlesyndication.com'),
|
||||
domain('adservice.google.com'),
|
||||
full('ads.yahoo.com'),
|
||||
keyword('adservice'),
|
||||
keyword('advertising'),
|
||||
regexp('^ad[0-9]{1,3}\\.'),
|
||||
...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
|
||||
],
|
||||
cloudflare: [
|
||||
domain('cloudflare.com'), domain('cloudflare-dns.com'), domain('cloudflareinsights.com'),
|
||||
domain('workers.dev'), domain('pages.dev'), domain('cf-ipfs.com'),
|
||||
domain('cloudflare.com'),
|
||||
domain('cloudflare-dns.com'),
|
||||
domain('cloudflareinsights.com'),
|
||||
domain('workers.dev'),
|
||||
domain('pages.dev'),
|
||||
domain('cf-ipfs.com'),
|
||||
],
|
||||
cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])],
|
||||
discord: [
|
||||
domain('discord.com'), domain('discord.gg'), domain('discordapp.com'),
|
||||
domain('discordapp.net'), domain('discord.media'),
|
||||
domain('discord.com'),
|
||||
domain('discord.gg'),
|
||||
domain('discordapp.com'),
|
||||
domain('discordapp.net'),
|
||||
domain('discord.media'),
|
||||
],
|
||||
facebook: [
|
||||
domain('facebook.com'), domain('fbcdn.net'), domain('fb.com'), domain('messenger.com'),
|
||||
domain('fbsbx.com'), domain('facebook.net'), full('m.facebook.com'),
|
||||
domain('facebook.com'),
|
||||
domain('fbcdn.net'),
|
||||
domain('fb.com'),
|
||||
domain('messenger.com'),
|
||||
domain('fbsbx.com'),
|
||||
domain('facebook.net'),
|
||||
full('m.facebook.com'),
|
||||
],
|
||||
'geolocation-!cn': [
|
||||
keyword('proxy'), regexp('.*\\.onion$'), domain('wikipedia.org'), domain('bbc.com'),
|
||||
domain('nytimes.com'), domain('reuters.com'), domain('medium.com'), domain('reddit.com'),
|
||||
keyword('proxy'),
|
||||
regexp('.*\\.onion$'),
|
||||
domain('wikipedia.org'),
|
||||
domain('bbc.com'),
|
||||
domain('nytimes.com'),
|
||||
domain('reuters.com'),
|
||||
domain('medium.com'),
|
||||
domain('reddit.com'),
|
||||
],
|
||||
'geolocation-cn': [
|
||||
domain('gov.cn'), domain('edu.cn'), domain('org.cn'), domain('net.cn'),
|
||||
domain('gov.cn'),
|
||||
domain('edu.cn'),
|
||||
domain('org.cn'),
|
||||
domain('net.cn'),
|
||||
...cross(CN_BRANDS.slice(0, 18), ['cn']),
|
||||
],
|
||||
github: [
|
||||
domain('github.com'), domain('githubusercontent.com'), domain('githubassets.com'),
|
||||
domain('github.io'), domain('ghcr.io'), domain('git.io'),
|
||||
domain('github.com'),
|
||||
domain('githubusercontent.com'),
|
||||
domain('githubassets.com'),
|
||||
domain('github.io'),
|
||||
domain('ghcr.io'),
|
||||
domain('git.io'),
|
||||
],
|
||||
google: [
|
||||
domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
|
||||
domain('googleusercontent.com'), domain('google-analytics.com'), domain('googletagmanager.com'),
|
||||
domain('ggpht.com'), domain('withgoogle.com'), domain('android.com'), domain('chromium.org'),
|
||||
domain('abc.xyz'), full('dl.google.com'), ...CC_TLDS.map((tld) => domain(`google.${tld}`)),
|
||||
domain('google.com'),
|
||||
domain('googleapis.com'),
|
||||
domain('gstatic.com'),
|
||||
domain('googleusercontent.com'),
|
||||
domain('google-analytics.com'),
|
||||
domain('googletagmanager.com'),
|
||||
domain('ggpht.com'),
|
||||
domain('withgoogle.com'),
|
||||
domain('android.com'),
|
||||
domain('chromium.org'),
|
||||
domain('abc.xyz'),
|
||||
full('dl.google.com'),
|
||||
...CC_TLDS.map((tld) => domain(`google.${tld}`)),
|
||||
],
|
||||
instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')],
|
||||
microsoft: [
|
||||
domain('microsoft.com'), domain('live.com'), domain('office.com'), domain('office365.com'),
|
||||
domain('windows.net'), domain('windowsupdate.com'), domain('msn.com'), domain('azure.com'),
|
||||
domain('sharepoint.com'), domain('skype.com'), domain('bing.com'),
|
||||
domain('microsoft.com'),
|
||||
domain('live.com'),
|
||||
domain('office.com'),
|
||||
domain('office365.com'),
|
||||
domain('windows.net'),
|
||||
domain('windowsupdate.com'),
|
||||
domain('msn.com'),
|
||||
domain('azure.com'),
|
||||
domain('sharepoint.com'),
|
||||
domain('skype.com'),
|
||||
domain('bing.com'),
|
||||
],
|
||||
netflix: [
|
||||
domain('netflix.com'), domain('netflix.net'), domain('nflximg.com'), domain('nflximg.net'),
|
||||
domain('nflxvideo.net'), domain('nflxso.net'), domain('nflxext.com'), full('fast.com'),
|
||||
domain('netflix.com'),
|
||||
domain('netflix.net'),
|
||||
domain('nflximg.com'),
|
||||
domain('nflximg.net'),
|
||||
domain('nflxvideo.net'),
|
||||
domain('nflxso.net'),
|
||||
domain('nflxext.com'),
|
||||
full('fast.com'),
|
||||
],
|
||||
openai: [
|
||||
domain('openai.com'), domain('chatgpt.com'), domain('oaistatic.com'),
|
||||
domain('oaiusercontent.com'), domain('sora.com'),
|
||||
domain('openai.com'),
|
||||
domain('chatgpt.com'),
|
||||
domain('oaistatic.com'),
|
||||
domain('oaiusercontent.com'),
|
||||
domain('sora.com'),
|
||||
],
|
||||
spotify: [
|
||||
domain('spotify.com'), domain('scdn.co'), domain('spotifycdn.com'), domain('spoti.fi'),
|
||||
domain('spotify.com'),
|
||||
domain('scdn.co'),
|
||||
domain('spotifycdn.com'),
|
||||
domain('spoti.fi'),
|
||||
domain('spotifycdn.net'),
|
||||
],
|
||||
steam: [
|
||||
domain('steampowered.com'), domain('steamcommunity.com'), domain('steamstatic.com'),
|
||||
domain('steamcontent.com'), domain('valvesoftware.com'),
|
||||
domain('steampowered.com'),
|
||||
domain('steamcommunity.com'),
|
||||
domain('steamstatic.com'),
|
||||
domain('steamcontent.com'),
|
||||
domain('valvesoftware.com'),
|
||||
],
|
||||
telegram: [
|
||||
domain('telegram.org'), domain('telegram.me'), domain('t.me'), domain('telesco.pe'),
|
||||
domain('tdesktop.com'), domain('telegra.ph'), domain('cdn-telegram.org'),
|
||||
full('comments.app'), keyword('telegram'),
|
||||
domain('telegram.org'),
|
||||
domain('telegram.me'),
|
||||
domain('t.me'),
|
||||
domain('telesco.pe'),
|
||||
domain('tdesktop.com'),
|
||||
domain('telegra.ph'),
|
||||
domain('cdn-telegram.org'),
|
||||
full('comments.app'),
|
||||
keyword('telegram'),
|
||||
],
|
||||
tiktok: [
|
||||
domain('tiktok.com'), domain('tiktokcdn.com'), domain('tiktokv.com'),
|
||||
domain('byteoversea.com'), domain('ibytedtos.com'), domain('musical.ly'),
|
||||
domain('tiktok.com'),
|
||||
domain('tiktokcdn.com'),
|
||||
domain('tiktokv.com'),
|
||||
domain('byteoversea.com'),
|
||||
domain('ibytedtos.com'),
|
||||
domain('musical.ly'),
|
||||
],
|
||||
twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')],
|
||||
twitter: [
|
||||
domain('twitter.com'), domain('x.com'), domain('t.co'), domain('twimg.com'),
|
||||
domain('twitter.com'),
|
||||
domain('x.com'),
|
||||
domain('t.co'),
|
||||
domain('twimg.com'),
|
||||
domain('periscope.tv'),
|
||||
],
|
||||
whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')],
|
||||
youtube: [
|
||||
domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com'),
|
||||
domain('youtube-nocookie.com'), domain('yt.be'),
|
||||
domain('youtube.com'),
|
||||
domain('youtu.be'),
|
||||
domain('ytimg.com'),
|
||||
domain('googlevideo.com'),
|
||||
domain('youtube-nocookie.com'),
|
||||
domain('yt.be'),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -192,68 +452,206 @@ const SITE_ATTRIBUTES: Record<string, string[]> = {
|
||||
};
|
||||
|
||||
const CN_BLOCKS = [
|
||||
'1.0.1.0/24', '1.0.2.0/23', '1.0.8.0/21', '14.0.12.0/22', '27.0.128.0/21', '36.0.0.0/22',
|
||||
'39.0.0.0/24', '42.0.0.0/22', '58.14.0.0/15', '59.32.0.0/11', '61.128.0.0/10', '101.16.0.0/12',
|
||||
'103.1.8.0/22', '106.0.0.0/10', '110.6.0.0/15', '111.0.0.0/10', '112.0.0.0/10', '113.0.0.0/9',
|
||||
'114.28.0.0/16', '116.0.0.0/9', '117.8.0.0/13', '118.24.0.0/15', '119.0.0.0/9', '120.0.0.0/10',
|
||||
'121.0.0.0/8', '124.0.0.0/8', '125.32.0.0/11', '139.196.0.0/14', '140.75.0.0/16', '175.0.0.0/12',
|
||||
'180.76.0.0/16', '182.16.0.0/12', '183.0.0.0/10', '202.0.0.0/12', '203.0.0.0/12', '210.0.0.0/12',
|
||||
'211.64.0.0/11', '218.0.0.0/9', '219.72.0.0/14', '220.112.0.0/12', '221.0.0.0/9', '222.16.0.0/12',
|
||||
'2001:250::/35', '2400:3200::/32', '2408:8000::/20',
|
||||
'1.0.1.0/24',
|
||||
'1.0.2.0/23',
|
||||
'1.0.8.0/21',
|
||||
'14.0.12.0/22',
|
||||
'27.0.128.0/21',
|
||||
'36.0.0.0/22',
|
||||
'39.0.0.0/24',
|
||||
'42.0.0.0/22',
|
||||
'58.14.0.0/15',
|
||||
'59.32.0.0/11',
|
||||
'61.128.0.0/10',
|
||||
'101.16.0.0/12',
|
||||
'103.1.8.0/22',
|
||||
'106.0.0.0/10',
|
||||
'110.6.0.0/15',
|
||||
'111.0.0.0/10',
|
||||
'112.0.0.0/10',
|
||||
'113.0.0.0/9',
|
||||
'114.28.0.0/16',
|
||||
'116.0.0.0/9',
|
||||
'117.8.0.0/13',
|
||||
'118.24.0.0/15',
|
||||
'119.0.0.0/9',
|
||||
'120.0.0.0/10',
|
||||
'121.0.0.0/8',
|
||||
'124.0.0.0/8',
|
||||
'125.32.0.0/11',
|
||||
'139.196.0.0/14',
|
||||
'140.75.0.0/16',
|
||||
'175.0.0.0/12',
|
||||
'180.76.0.0/16',
|
||||
'182.16.0.0/12',
|
||||
'183.0.0.0/10',
|
||||
'202.0.0.0/12',
|
||||
'203.0.0.0/12',
|
||||
'210.0.0.0/12',
|
||||
'211.64.0.0/11',
|
||||
'218.0.0.0/9',
|
||||
'219.72.0.0/14',
|
||||
'220.112.0.0/12',
|
||||
'221.0.0.0/9',
|
||||
'222.16.0.0/12',
|
||||
'2001:250::/35',
|
||||
'2400:3200::/32',
|
||||
'2408:8000::/20',
|
||||
];
|
||||
|
||||
const CN_EXTRA_BLOCKS = Array.from({ length: 96 }, (_, index) =>
|
||||
`${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
|
||||
const CN_EXTRA_BLOCKS = Array.from(
|
||||
{ length: 96 },
|
||||
(_, index) => `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
|
||||
);
|
||||
|
||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
cloudflare: [
|
||||
'103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '104.16.0.0/13', '104.24.0.0/14',
|
||||
'108.162.192.0/18', '131.0.72.0/22', '141.101.64.0/18', '162.158.0.0/15', '172.64.0.0/13',
|
||||
'173.245.48.0/20', '188.114.96.0/20', '190.93.240.0/20', '197.234.240.0/22', '198.41.128.0/17',
|
||||
'2400:cb00::/32', '2606:4700::/32',
|
||||
'103.21.244.0/22',
|
||||
'103.22.200.0/22',
|
||||
'103.31.4.0/22',
|
||||
'104.16.0.0/13',
|
||||
'104.24.0.0/14',
|
||||
'108.162.192.0/18',
|
||||
'131.0.72.0/22',
|
||||
'141.101.64.0/18',
|
||||
'162.158.0.0/15',
|
||||
'172.64.0.0/13',
|
||||
'173.245.48.0/20',
|
||||
'188.114.96.0/20',
|
||||
'190.93.240.0/20',
|
||||
'197.234.240.0/22',
|
||||
'198.41.128.0/17',
|
||||
'2400:cb00::/32',
|
||||
'2606:4700::/32',
|
||||
].map(cidr),
|
||||
cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr),
|
||||
facebook: [
|
||||
'31.13.24.0/21', '31.13.64.0/18', '66.220.144.0/20', '69.63.176.0/20', '69.171.224.0/19',
|
||||
'157.240.0.0/16', '179.60.192.0/22', '185.60.216.0/22', '2a03:2880::/32',
|
||||
'31.13.24.0/21',
|
||||
'31.13.64.0/18',
|
||||
'66.220.144.0/20',
|
||||
'69.63.176.0/20',
|
||||
'69.171.224.0/19',
|
||||
'157.240.0.0/16',
|
||||
'179.60.192.0/22',
|
||||
'185.60.216.0/22',
|
||||
'2a03:2880::/32',
|
||||
].map(cidr),
|
||||
google: [
|
||||
'8.8.4.0/24', '8.8.8.0/24', '34.64.0.0/10', '35.184.0.0/13', '64.233.160.0/19', '66.102.0.0/20',
|
||||
'72.14.192.0/18', '74.125.0.0/16', '108.177.8.0/21', '142.250.0.0/15', '172.217.0.0/16',
|
||||
'216.58.192.0/19', '2404:6800::/32', '2607:f8b0::/32',
|
||||
'8.8.4.0/24',
|
||||
'8.8.8.0/24',
|
||||
'34.64.0.0/10',
|
||||
'35.184.0.0/13',
|
||||
'64.233.160.0/19',
|
||||
'66.102.0.0/20',
|
||||
'72.14.192.0/18',
|
||||
'74.125.0.0/16',
|
||||
'108.177.8.0/21',
|
||||
'142.250.0.0/15',
|
||||
'172.217.0.0/16',
|
||||
'216.58.192.0/19',
|
||||
'2404:6800::/32',
|
||||
'2607:f8b0::/32',
|
||||
].map(cidr),
|
||||
ir: [
|
||||
'2.144.0.0/14', '5.22.0.0/17', '31.2.128.0/17', '37.32.0.0/19', '46.32.0.0/19', '78.38.0.0/15',
|
||||
'80.191.0.0/16', '85.15.0.0/18', '91.98.0.0/15', '178.22.72.0/21', '185.8.172.0/22',
|
||||
'188.34.0.0/17', '217.218.0.0/15',
|
||||
'2.144.0.0/14',
|
||||
'5.22.0.0/17',
|
||||
'31.2.128.0/17',
|
||||
'37.32.0.0/19',
|
||||
'46.32.0.0/19',
|
||||
'78.38.0.0/15',
|
||||
'80.191.0.0/16',
|
||||
'85.15.0.0/18',
|
||||
'91.98.0.0/15',
|
||||
'178.22.72.0/21',
|
||||
'185.8.172.0/22',
|
||||
'188.34.0.0/17',
|
||||
'217.218.0.0/15',
|
||||
].map(cidr),
|
||||
netflix: [
|
||||
'23.246.0.0/18', '37.77.184.0/21', '45.57.0.0/17', '64.120.128.0/17', '66.197.128.0/17',
|
||||
'108.175.32.0/20', '185.2.220.0/22', '192.173.64.0/18', '198.38.96.0/19', '198.45.48.0/20',
|
||||
'23.246.0.0/18',
|
||||
'37.77.184.0/21',
|
||||
'45.57.0.0/17',
|
||||
'64.120.128.0/17',
|
||||
'66.197.128.0/17',
|
||||
'108.175.32.0/20',
|
||||
'185.2.220.0/22',
|
||||
'192.173.64.0/18',
|
||||
'198.38.96.0/19',
|
||||
'198.45.48.0/20',
|
||||
].map(cidr),
|
||||
private: [
|
||||
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
|
||||
'192.0.0.0/24', '192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24',
|
||||
'203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32', '::1/128', 'fc00::/7',
|
||||
'0.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'100.64.0.0/10',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'172.16.0.0/12',
|
||||
'192.0.0.0/24',
|
||||
'192.0.2.0/24',
|
||||
'192.168.0.0/16',
|
||||
'198.18.0.0/15',
|
||||
'198.51.100.0/24',
|
||||
'203.0.113.0/24',
|
||||
'224.0.0.0/4',
|
||||
'240.0.0.0/4',
|
||||
'255.255.255.255/32',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
].map(cidr),
|
||||
ru: [
|
||||
'2.60.0.0/14', '5.8.0.0/19', '31.6.0.0/17', '37.9.0.0/19', '46.16.0.0/21', '62.76.0.0/18',
|
||||
'77.37.128.0/17', '78.24.216.0/21', '79.104.0.0/15', '80.64.128.0/19', '81.16.96.0/19',
|
||||
'82.140.128.0/18', '85.113.0.0/16', '87.226.0.0/16', '91.77.0.0/16', '93.157.0.0/17',
|
||||
'94.19.0.0/16', '95.24.0.0/13', '178.176.0.0/13', '188.128.0.0/13', '213.87.0.0/16',
|
||||
'217.66.152.0/21', '2a00:1148::/32',
|
||||
'2.60.0.0/14',
|
||||
'5.8.0.0/19',
|
||||
'31.6.0.0/17',
|
||||
'37.9.0.0/19',
|
||||
'46.16.0.0/21',
|
||||
'62.76.0.0/18',
|
||||
'77.37.128.0/17',
|
||||
'78.24.216.0/21',
|
||||
'79.104.0.0/15',
|
||||
'80.64.128.0/19',
|
||||
'81.16.96.0/19',
|
||||
'82.140.128.0/18',
|
||||
'85.113.0.0/16',
|
||||
'87.226.0.0/16',
|
||||
'91.77.0.0/16',
|
||||
'93.157.0.0/17',
|
||||
'94.19.0.0/16',
|
||||
'95.24.0.0/13',
|
||||
'178.176.0.0/13',
|
||||
'188.128.0.0/13',
|
||||
'213.87.0.0/16',
|
||||
'217.66.152.0/21',
|
||||
'2a00:1148::/32',
|
||||
].map(cidr),
|
||||
telegram: [
|
||||
'91.108.4.0/22', '91.108.8.0/22', '91.108.12.0/22', '91.108.16.0/22', '91.108.20.0/22',
|
||||
'91.108.56.0/22', '149.154.160.0/20', '2001:67c:4e8::/48', '2001:b28:f23d::/48',
|
||||
'91.108.4.0/22',
|
||||
'91.108.8.0/22',
|
||||
'91.108.12.0/22',
|
||||
'91.108.16.0/22',
|
||||
'91.108.20.0/22',
|
||||
'91.108.56.0/22',
|
||||
'149.154.160.0/20',
|
||||
'2001:67c:4e8::/48',
|
||||
'2001:b28:f23d::/48',
|
||||
'2001:b28:f23f::/48',
|
||||
].map(cidr),
|
||||
us: [
|
||||
'3.0.0.0/9', '12.0.0.0/8', '23.192.0.0/11', '34.192.0.0/10', '50.16.0.0/14', '52.0.0.0/10',
|
||||
'63.64.0.0/11', '65.0.0.0/10', '68.32.0.0/11', '71.0.0.0/11', '96.0.0.0/9', '128.0.0.0/10',
|
||||
'199.0.0.0/12', '208.64.0.0/12', '2600:1f00::/24',
|
||||
'3.0.0.0/9',
|
||||
'12.0.0.0/8',
|
||||
'23.192.0.0/11',
|
||||
'34.192.0.0/10',
|
||||
'50.16.0.0/14',
|
||||
'52.0.0.0/10',
|
||||
'63.64.0.0/11',
|
||||
'65.0.0.0/10',
|
||||
'68.32.0.0/11',
|
||||
'71.0.0.0/11',
|
||||
'96.0.0.0/9',
|
||||
'128.0.0.0/10',
|
||||
'199.0.0.0/12',
|
||||
'208.64.0.0/12',
|
||||
'2600:1f00::/24',
|
||||
].map(cidr),
|
||||
};
|
||||
|
||||
@@ -305,10 +703,11 @@ const OVERSIZED_FILE: GeoFile = {
|
||||
error: 'geodata file is too large to browse',
|
||||
};
|
||||
|
||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
|
||||
'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
|
||||
'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
|
||||
};
|
||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
|
||||
{
|
||||
'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
|
||||
'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
|
||||
};
|
||||
|
||||
function routesFor(files: GeoFile[]): GeoRoutes {
|
||||
return {
|
||||
@@ -316,7 +715,9 @@ function routesFor(files: GeoFile[]): GeoRoutes {
|
||||
'/panel/api/xray/geodata/categories': (query) => {
|
||||
const dataset = DATASETS[query.get('file') ?? ''];
|
||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
||||
const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
|
||||
const items = (dataset?.categories ?? []).filter((category) =>
|
||||
category.code.includes(needle),
|
||||
);
|
||||
return { total: items.length, items };
|
||||
},
|
||||
'/panel/api/xray/geodata/entries': (query) => {
|
||||
@@ -348,10 +749,14 @@ const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
|
||||
function BrowserDemo(props: GeoBrowserModalProps) {
|
||||
const [open, setOpen] = useState(props.open);
|
||||
const [value, setValue] = useState(props.value);
|
||||
useEffect(() => setOpen(props.open), [props.open]);
|
||||
useEffect(() => setValue(props.value), [props.value]);
|
||||
const [synced, setSynced] = useState({ open: props.open, value: props.value });
|
||||
if (synced.open !== props.open || synced.value !== props.value) {
|
||||
setSynced({ open: props.open, value: props.value });
|
||||
setOpen(props.open);
|
||||
setValue(props.value);
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={12}>
|
||||
<Space orientation="vertical" size={12}>
|
||||
<Space size={8}>
|
||||
<Button onClick={() => setOpen(true)}>Open geo browser</Button>
|
||||
<Typography.Text code>{value || 'no rule yet'}</Typography.Text>
|
||||
@@ -398,12 +803,14 @@ const meta = {
|
||||
argTypes: {
|
||||
open: { description: 'Whether the modal is visible.' },
|
||||
kind: {
|
||||
description: 'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
|
||||
description:
|
||||
'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
|
||||
control: 'inline-radio',
|
||||
options: ['site', 'ip'],
|
||||
},
|
||||
value: {
|
||||
description: 'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
|
||||
description:
|
||||
'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
|
||||
},
|
||||
onApply: { description: 'Called with the merged rule string when Apply is pressed.' },
|
||||
onClose: { description: 'Called when the modal is dismissed.' },
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Empty, Input, Modal, Pagination, Select, Space, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata';
|
||||
@@ -25,7 +38,9 @@ export interface GeoBrowserModalProps {
|
||||
// A geosite category inside an ip rule (or the reverse) is a config Xray will
|
||||
// reject, so a field only ever offers databases of its own kind.
|
||||
function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] {
|
||||
return files.filter((file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)));
|
||||
return files.filter(
|
||||
(file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)),
|
||||
);
|
||||
}
|
||||
|
||||
function namePrefersKind(name: string, kind: GeoKind): boolean {
|
||||
@@ -38,7 +53,13 @@ function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined {
|
||||
return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name;
|
||||
}
|
||||
|
||||
export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: GeoBrowserModalProps) {
|
||||
export default function GeoBrowserModal({
|
||||
open,
|
||||
kind,
|
||||
value,
|
||||
onApply,
|
||||
onClose,
|
||||
}: GeoBrowserModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<string | undefined>(undefined);
|
||||
const [categoryQuery, setCategoryQuery] = useState('');
|
||||
@@ -48,13 +69,16 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
const [entryPage, setEntryPage] = useState(1);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const knownRef = useRef<Set<string>>(new Set());
|
||||
const seededFilesRef = useRef<Set<string>>(new Set());
|
||||
const [known, setKnown] = useState<Set<string>>(() => new Set());
|
||||
const [seededFiles, setSeededFiles] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const filesQuery = useGeodataFiles(open);
|
||||
const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
|
||||
const activeFile = files.find((candidate) => candidate.name === file);
|
||||
const fileKind: GeoKind = activeFile?.kind ?? kind;
|
||||
const activeFile = useMemo(
|
||||
() => files.find((candidate) => candidate.name === file),
|
||||
[files, file],
|
||||
);
|
||||
const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, kind]);
|
||||
|
||||
const categoriesQuery = useGeodataCategories(file, '', open && !!file);
|
||||
// While a newly picked database loads, the query still serves the previous
|
||||
@@ -96,33 +120,35 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [entryQuery, entryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
knownRef.current = new Set();
|
||||
seededFilesRef.current = new Set();
|
||||
setCategoryQuery('');
|
||||
setEntryQuery('');
|
||||
setEntryFilter('');
|
||||
setActiveCode(undefined);
|
||||
setEntryPage(1);
|
||||
setSelected([]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || file || files.length === 0) return;
|
||||
// Opening, picking the default database and seeding the selection are all
|
||||
// render-time adjustments — an effect would paint the previous state first.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setKnown(new Set());
|
||||
setSeededFiles(new Set());
|
||||
setCategoryQuery('');
|
||||
setEntryQuery('');
|
||||
setEntryFilter('');
|
||||
setActiveCode(undefined);
|
||||
setEntryPage(1);
|
||||
setSelected([]);
|
||||
}
|
||||
} else if (open && !file && files.length > 0) {
|
||||
setFile(preferredFile(files, kind));
|
||||
}, [open, file, files, kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !file || categories.length === 0 || seededFilesRef.current.has(file)) return;
|
||||
} else if (open && file && categories.length > 0 && !seededFiles.has(file)) {
|
||||
const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
|
||||
for (const token of tokens) knownRef.current.add(token);
|
||||
seededFilesRef.current.add(file);
|
||||
setKnown(new Set([...known, ...tokens]));
|
||||
setSeededFiles(new Set(seededFiles).add(file));
|
||||
const fromValue = selectionFromValue(value, new Set(tokens));
|
||||
if (fromValue.length > 0) {
|
||||
setSelected((previous) => [...previous, ...fromValue.filter((token) => !previous.includes(token))]);
|
||||
setSelected((previous) => [
|
||||
...previous,
|
||||
...fromValue.filter((token) => !previous.includes(token)),
|
||||
]);
|
||||
}
|
||||
}, [open, file, categories, fileKind, value]);
|
||||
}
|
||||
|
||||
const visibleCategories = useMemo(() => {
|
||||
const query = categoryQuery.trim().toLowerCase();
|
||||
@@ -149,7 +175,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
// The table reports keys for the rows it currently shows, so a selection
|
||||
// made before the search box was narrowed must survive untouched.
|
||||
const shown = new Set(
|
||||
visibleCategories.map((category) => canonicalToken(tokenFor(file, category.code, fileKind))),
|
||||
visibleCategories.map((category) =>
|
||||
canonicalToken(tokenFor(file, category.code, fileKind)),
|
||||
),
|
||||
);
|
||||
setSelected((previous) => {
|
||||
const kept = previous.filter((token) => {
|
||||
@@ -157,7 +185,10 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
return !shown.has(canonical) || chosenCanonical.has(canonical);
|
||||
});
|
||||
const keptCanonical = new Set(kept.map(canonicalToken));
|
||||
return [...kept, ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token)))];
|
||||
return [
|
||||
...kept,
|
||||
...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token))),
|
||||
];
|
||||
});
|
||||
},
|
||||
[visibleCategories, file, fileKind],
|
||||
@@ -174,7 +205,7 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
{category.attributes?.length > 0 && (
|
||||
<span className="geo-attrs">
|
||||
{category.attributes.map((attribute) => (
|
||||
<Tag key={attribute} bordered={false}>
|
||||
<Tag key={attribute} variant="filled">
|
||||
@{attribute}
|
||||
</Tag>
|
||||
))}
|
||||
@@ -199,7 +230,7 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
dataIndex: 'kind',
|
||||
width: 88,
|
||||
render: (entryKind: string) => (
|
||||
<Tag bordered={false} className={`geo-kind geo-kind-${entryKind}`}>
|
||||
<Tag variant="filled" className={`geo-kind geo-kind-${entryKind}`}>
|
||||
{entryKind}
|
||||
</Tag>
|
||||
),
|
||||
@@ -214,7 +245,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
|
||||
const fileOptions = files.map((candidate) => ({
|
||||
value: candidate.name,
|
||||
label: candidate.error ? `${candidate.name} — ${describeFileError(candidate.error, t)}` : candidate.name,
|
||||
label: candidate.error
|
||||
? `${candidate.name} — ${describeFileError(candidate.error, t)}`
|
||||
: candidate.name,
|
||||
disabled: !!candidate.error,
|
||||
}));
|
||||
|
||||
@@ -229,9 +262,14 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
const entriesTotal = entriesQuery.data?.total ?? 0;
|
||||
const activeCategory = categories.find((category) => category.code === activeCode);
|
||||
const countLabel = activeCategory
|
||||
? t(fileKind === 'ip' ? 'pages.xray.geoBrowser.subnetsCount' : 'pages.xray.geoBrowser.entriesCount', {
|
||||
count: activeCategory.entries.toLocaleString(),
|
||||
})
|
||||
? t(
|
||||
fileKind === 'ip'
|
||||
? 'pages.xray.geoBrowser.subnetsCount'
|
||||
: 'pages.xray.geoBrowser.entriesCount',
|
||||
{
|
||||
count: activeCategory.entries.toLocaleString(),
|
||||
},
|
||||
)
|
||||
: '';
|
||||
|
||||
return (
|
||||
@@ -240,12 +278,19 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
title={t('pages.xray.geoBrowser.title')}
|
||||
width={880}
|
||||
onCancel={onClose}
|
||||
onOk={() => onApply(mergeSelection(value, selected, knownRef.current))}
|
||||
onOk={() => onApply(mergeSelection(value, selected, known))}
|
||||
okText={t('pages.xray.geoBrowser.apply')}
|
||||
cancelText={t('close')}
|
||||
className="geo-browser-modal"
|
||||
>
|
||||
{filesQuery.isError && <Alert type="error" showIcon title={t('pages.xray.geoBrowser.loadFailed')} className="mb-12" />}
|
||||
{filesQuery.isError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.xray.geoBrowser.loadFailed')}
|
||||
className="mb-12"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
|
||||
<Empty
|
||||
@@ -253,7 +298,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
<span>
|
||||
{t('pages.xray.geoBrowser.noFiles')}
|
||||
<br />
|
||||
<Typography.Text type="secondary">{t('pages.xray.geoBrowser.noFilesHint')}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.xray.geoBrowser.noFilesHint')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -279,7 +326,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
onClick={() => toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])}
|
||||
onClick={() =>
|
||||
toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])
|
||||
}
|
||||
disabled={visibleCategories.length === 0}
|
||||
>
|
||||
{`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
|
||||
@@ -296,7 +345,11 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
rowKey="code"
|
||||
columns={categoryColumns}
|
||||
dataSource={visibleCategories}
|
||||
loading={filesQuery.isLoading || categoriesQuery.isLoading || categoriesQuery.isPlaceholderData}
|
||||
loading={
|
||||
filesQuery.isLoading ||
|
||||
categoriesQuery.isLoading ||
|
||||
categoriesQuery.isPlaceholderData
|
||||
}
|
||||
pagination={false}
|
||||
scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
|
||||
locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
|
||||
@@ -308,7 +361,8 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
}}
|
||||
onRow={(category) => ({
|
||||
onClick: (event) => {
|
||||
if ((event.target as HTMLElement).closest('.ant-table-selection-column')) return;
|
||||
if ((event.target as HTMLElement).closest('.ant-table-selection-column'))
|
||||
return;
|
||||
setActiveCode(category.code);
|
||||
clearEntryFilter();
|
||||
},
|
||||
@@ -369,7 +423,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
</>
|
||||
) : (
|
||||
<div className="geo-placeholder">
|
||||
<Typography.Text type="secondary">{t('pages.xray.geoBrowser.pickCategory')}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.xray.geoBrowser.pickCategory')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -377,7 +433,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
|
||||
<div className="geo-footer">
|
||||
{selected.length === 0 ? (
|
||||
<Typography.Text type="secondary">{t('pages.xray.geoBrowser.emptySelection')}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.xray.geoBrowser.emptySelection')}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
<Space size={4} wrap className="geo-chips">
|
||||
@@ -386,7 +444,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
key={token}
|
||||
closable
|
||||
color="processing"
|
||||
onClose={() => setSelected((previous) => previous.filter((item) => item !== token))}
|
||||
onClose={() =>
|
||||
setSelected((previous) => previous.filter((item) => item !== token))
|
||||
}
|
||||
>
|
||||
{token}
|
||||
</Tag>
|
||||
|
||||
@@ -44,7 +44,9 @@ function deactivate(routes: GeoRoutes): void {
|
||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
||||
const [client] = useState(() => {
|
||||
activate(routes);
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
});
|
||||
useEffect(() => {
|
||||
activate(routes);
|
||||
@@ -58,25 +60,55 @@ const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
|
||||
|
||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
'category-ads-all': [
|
||||
domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
|
||||
domain('criteo.com'), domain('taboola.com'), domain('outbrain.com'),
|
||||
domain('doubleclick.net'),
|
||||
domain('googleadservices.com'),
|
||||
domain('googlesyndication.com'),
|
||||
domain('criteo.com'),
|
||||
domain('taboola.com'),
|
||||
domain('outbrain.com'),
|
||||
],
|
||||
cn: [
|
||||
domain('baidu.com'),
|
||||
domain('qq.com'),
|
||||
domain('taobao.com'),
|
||||
domain('weibo.com'),
|
||||
domain('bilibili.com'),
|
||||
],
|
||||
cn: [domain('baidu.com'), domain('qq.com'), domain('taobao.com'), domain('weibo.com'), domain('bilibili.com')],
|
||||
google: [
|
||||
domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
|
||||
domain('googleusercontent.com'), domain('ggpht.com'), domain('android.com'),
|
||||
domain('google.com'),
|
||||
domain('googleapis.com'),
|
||||
domain('gstatic.com'),
|
||||
domain('googleusercontent.com'),
|
||||
domain('ggpht.com'),
|
||||
domain('android.com'),
|
||||
],
|
||||
netflix: [
|
||||
domain('netflix.com'),
|
||||
domain('nflximg.net'),
|
||||
domain('nflxvideo.net'),
|
||||
domain('fast.com'),
|
||||
],
|
||||
netflix: [domain('netflix.com'), domain('nflximg.net'), domain('nflxvideo.net'), domain('fast.com')],
|
||||
telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')],
|
||||
youtube: [domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com')],
|
||||
youtube: [
|
||||
domain('youtube.com'),
|
||||
domain('youtu.be'),
|
||||
domain('ytimg.com'),
|
||||
domain('googlevideo.com'),
|
||||
],
|
||||
};
|
||||
|
||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr),
|
||||
cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr),
|
||||
private: [
|
||||
'10.0.0.0/8', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16',
|
||||
'::1/128', 'fc00::/7', 'fe80::/10',
|
||||
'10.0.0.0/8',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
].map(cidr),
|
||||
telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr),
|
||||
};
|
||||
@@ -95,10 +127,14 @@ function categoriesOf(
|
||||
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
|
||||
}
|
||||
|
||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
|
||||
'geosite.dat': { categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), entries: SITE_ENTRIES },
|
||||
'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
|
||||
};
|
||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
|
||||
{
|
||||
'geosite.dat': {
|
||||
categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES),
|
||||
entries: SITE_ENTRIES,
|
||||
},
|
||||
'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
|
||||
};
|
||||
|
||||
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
|
||||
|
||||
@@ -125,7 +161,9 @@ function referenceOf(token: string, isIP: boolean): { file: string; code: string
|
||||
if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) };
|
||||
if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) };
|
||||
if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) };
|
||||
return isIP && prefix === 'ext-ip' ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) } : null;
|
||||
return isIP && prefix === 'ext-ip'
|
||||
? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) }
|
||||
: null;
|
||||
}
|
||||
|
||||
function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] {
|
||||
@@ -178,9 +216,13 @@ const withGeodata: Decorator = function GeodataBackend(Story) {
|
||||
|
||||
function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
|
||||
const [current, setCurrent] = useState(value);
|
||||
useEffect(() => setCurrent(value), [value]);
|
||||
const [synced, setSynced] = useState(value);
|
||||
if (synced !== value) {
|
||||
setSynced(value);
|
||||
setCurrent(value);
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={4} style={{ width: 460 }}>
|
||||
<Space orientation="vertical" size={4} style={{ width: 460 }}>
|
||||
<label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
|
||||
<GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
|
||||
</Space>
|
||||
@@ -209,10 +251,15 @@ const meta = {
|
||||
args: { kind: 'domain' },
|
||||
argTypes: {
|
||||
value: { description: 'Comma separated rule string held by the parent form.' },
|
||||
onChange: { description: 'Called with the full rule string on every edit and on Apply from the browser.' },
|
||||
onBlur: { description: 'Forwarded to the input; used by React Hook Form to mark the field touched.' },
|
||||
onChange: {
|
||||
description: 'Called with the full rule string on every edit and on Apply from the browser.',
|
||||
},
|
||||
onBlur: {
|
||||
description: 'Forwarded to the input; used by React Hook Form to mark the field touched.',
|
||||
},
|
||||
kind: {
|
||||
description: 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
|
||||
description:
|
||||
'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
|
||||
control: 'inline-radio',
|
||||
options: ['domain', 'ip'],
|
||||
},
|
||||
@@ -242,6 +289,8 @@ export const UnknownCategory: Story = {
|
||||
args: { kind: 'domain', value: 'geosite:blabla, geosite:google' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 })).toBeVisible();
|
||||
await expect(
|
||||
await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Ref } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Tooltip, Typography } from 'antd';
|
||||
import { Button, Input, Space, Tooltip, Typography } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { DatabaseOutlined } from '@ant-design/icons';
|
||||
|
||||
@@ -33,7 +33,15 @@ export interface GeoTokenInputProps {
|
||||
ref?: Ref<InputRef>;
|
||||
}
|
||||
|
||||
export default function GeoTokenInput({ value = '', onChange, onBlur, kind, placeholder, id, ref }: GeoTokenInputProps) {
|
||||
export default function GeoTokenInput({
|
||||
value = '',
|
||||
onChange,
|
||||
onBlur,
|
||||
kind,
|
||||
placeholder,
|
||||
id,
|
||||
ref,
|
||||
}: GeoTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
|
||||
@@ -41,13 +49,21 @@ export default function GeoTokenInput({ value = '', onChange, onBlur, kind, plac
|
||||
const validate = useValidateGeoTokens();
|
||||
const { mutateAsync } = validate;
|
||||
|
||||
useEffect(() => {
|
||||
const tokens = parseTokens(value);
|
||||
if (tokens.length === 0) {
|
||||
// An empty field has nothing to validate, so it clears during render rather
|
||||
// than waiting a commit for the effect to catch up.
|
||||
const isEmpty = parseTokens(value).length === 0;
|
||||
const [wasEmpty, setWasEmpty] = useState(isEmpty);
|
||||
if (isEmpty !== wasEmpty) {
|
||||
setWasEmpty(isEmpty);
|
||||
if (isEmpty) {
|
||||
setIssues([]);
|
||||
setCheckFailed(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const tokens = parseTokens(value);
|
||||
if (tokens.length === 0) return;
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
mutateAsync({ tokens, kind })
|
||||
@@ -72,25 +88,23 @@ export default function GeoTokenInput({ value = '', onChange, onBlur, kind, plac
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
ref={ref}
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onBlur={onBlur}
|
||||
addonAfter={
|
||||
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DatabaseOutlined />}
|
||||
aria-label={t('pages.xray.geoBrowser.openTooltip')}
|
||||
onClick={() => setBrowsing(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Space.Compact block>
|
||||
<Input
|
||||
ref={ref}
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
|
||||
<Button
|
||||
icon={<DatabaseOutlined />}
|
||||
aria-label={t('pages.xray.geoBrowser.openTooltip')}
|
||||
onClick={() => setBrowsing(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
{groupByReason(issues).map(([reason, tokens]) => (
|
||||
<Typography.Text key={reason} type="warning" className="geo-unknown-hint">
|
||||
{t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults';
|
||||
* default?", not "has the user ever saved this key?" — a stored 2096 and a
|
||||
* fallback 2096 behave identically, so they read identically.
|
||||
*/
|
||||
export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean {
|
||||
export function matchesFactoryDefault(
|
||||
current: unknown,
|
||||
factoryDefault: string | undefined,
|
||||
): boolean {
|
||||
if (factoryDefault === undefined) return false;
|
||||
if (typeof current === 'number') {
|
||||
const parsed = Number(factoryDefault);
|
||||
|
||||
@@ -10,8 +10,17 @@ interface InputAddonProps {
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export default function InputAddon({ children, className = '', style, onClick, ariaLabel }: InputAddonProps) {
|
||||
export default function InputAddon({
|
||||
children,
|
||||
className = '',
|
||||
style,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
}: InputAddonProps) {
|
||||
return (
|
||||
// oxlint cannot see through the conditional role/tabIndex/onKeyDown below,
|
||||
// which is exactly what makes the clickable variant accessible.
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<span
|
||||
className={`input-addon ${className}`.trim()}
|
||||
style={style}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { cloneElement, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from 'react';
|
||||
import {
|
||||
cloneElement,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
useId,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Col, Row } from 'antd';
|
||||
import './SettingListItem.css';
|
||||
|
||||
@@ -22,9 +29,12 @@ export default function SettingListItem({
|
||||
const padding = paddings === 'small' ? '10px 20px' : '20px';
|
||||
const titleId = useId();
|
||||
const node = control ?? children;
|
||||
const labelledNode = title && isValidElement(node) && node.type !== Fragment
|
||||
? cloneElement(node as ReactElement<{ 'aria-labelledby'?: string }>, { 'aria-labelledby': titleId })
|
||||
: node;
|
||||
const labelledNode =
|
||||
title && isValidElement(node) && node.type !== Fragment
|
||||
? cloneElement(node as ReactElement<{ 'aria-labelledby'?: string }>, {
|
||||
'aria-labelledby': titleId,
|
||||
})
|
||||
: node;
|
||||
return (
|
||||
<div className="setting-list-item" style={{ padding }}>
|
||||
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
||||
|
||||
@@ -23,7 +23,8 @@ const meta = {
|
||||
'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.',
|
||||
description:
|
||||
'Receives a partial settings patch when an event is toggled or a threshold input changes.',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof EmailNotifications>;
|
||||
@@ -56,7 +57,9 @@ export const SystemThresholdAlerts: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => (
|
||||
<StatefulDemo
|
||||
initial={new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })}
|
||||
initial={
|
||||
new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -64,7 +67,9 @@ export const SystemThresholdAlerts: Story = {
|
||||
export const InfrastructureOnly: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => (
|
||||
<StatefulDemo initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })} />
|
||||
<StatefulDemo
|
||||
initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { InputNumber } from 'antd';
|
||||
import { CloudServerOutlined, ThunderboltOutlined, DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
ThunderboltOutlined,
|
||||
DesktopOutlined,
|
||||
DashboardOutlined,
|
||||
SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { NotificationLayout } from './NotificationLayout';
|
||||
import { NotificationGroup } from './NotificationGroup';
|
||||
@@ -15,7 +21,15 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
label: 'eventOutboundDown',
|
||||
settingKey: 'outboundDownThreshold',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||
@@ -24,9 +38,7 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
{
|
||||
icon: <ThunderboltOutlined />,
|
||||
title: 'eventGroupXray',
|
||||
events: [
|
||||
{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' },
|
||||
],
|
||||
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
|
||||
},
|
||||
{
|
||||
icon: <DesktopOutlined />,
|
||||
@@ -45,7 +57,15 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
label: 'eventCPUHigh',
|
||||
settingKey: 'smtpCpu',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -53,7 +73,15 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
label: 'eventMemoryHigh',
|
||||
settingKey: 'smtpMemory',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -61,9 +89,7 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
{
|
||||
icon: <SafetyOutlined />,
|
||||
title: 'eventGroupSecurity',
|
||||
events: [
|
||||
{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' },
|
||||
],
|
||||
events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -74,12 +100,15 @@ interface Props {
|
||||
|
||||
export function EmailNotifications({ allSetting, updateSetting }: Props) {
|
||||
const events = allSetting.smtpEnabledEvents || '';
|
||||
const selected = events ? events.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const selected = events
|
||||
? events
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
function toggle(key: string) {
|
||||
const next = selected.includes(key)
|
||||
? selected.filter((e) => e !== key)
|
||||
: [...selected, key];
|
||||
const next = selected.includes(key) ? selected.filter((e) => e !== key) : [...selected, key];
|
||||
updateSetting({ smtpEnabledEvents: next.join(',') });
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ export function NotificationCard({ icon, title, extra, children }: Props) {
|
||||
<Card
|
||||
size="small"
|
||||
variant="outlined"
|
||||
title={<span>{icon} {title}</span>}
|
||||
title={
|
||||
<span>
|
||||
{icon} {title}
|
||||
</span>
|
||||
}
|
||||
extra={extra}
|
||||
style={{ borderWidth: 1 }}
|
||||
>
|
||||
|
||||
@@ -16,11 +16,7 @@ export function NotificationEvent({ label, checked, onToggle, children }: Props)
|
||||
<Checkbox checked={checked} onChange={onToggle}>
|
||||
{t(label)}
|
||||
</Checkbox>
|
||||
{checked && children && (
|
||||
<div style={{ paddingLeft: 24, marginTop: 4 }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{checked && children && <div style={{ paddingLeft: 24, marginTop: 4 }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,15 @@ const systemGroup: NotificationGroupConfig = {
|
||||
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 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -24,7 +32,15 @@ const systemGroup: NotificationGroupConfig = {
|
||||
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 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -53,12 +69,21 @@ const meta = {
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
config: { description: 'Group definition: icon, `pages.settings` title key, and the event rows to render.' },
|
||||
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.' },
|
||||
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>;
|
||||
|
||||
@@ -77,7 +102,11 @@ function Demo() {
|
||||
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])]))
|
||||
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 }))}
|
||||
|
||||
@@ -15,7 +15,14 @@ interface Props {
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
}
|
||||
|
||||
export function NotificationGroup({ config, selected, onToggle, onToggleAll, allSetting, updateSetting }: Props) {
|
||||
export function NotificationGroup({
|
||||
config,
|
||||
selected,
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
allSetting,
|
||||
updateSetting,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const count = config.events.filter((e) => selected.includes(e.key)).length;
|
||||
@@ -49,7 +56,8 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
|
||||
onToggle={() => onToggle(event.key)}
|
||||
>
|
||||
{event.extra?.({
|
||||
value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
|
||||
value:
|
||||
Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
|
||||
onChange: (v) => updateSetting({ [event.settingKey]: v }),
|
||||
ariaLabel: t(`pages.settings.${event.label}`),
|
||||
})}
|
||||
|
||||
@@ -22,7 +22,9 @@ const meta = {
|
||||
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.' },
|
||||
onToggleAll: {
|
||||
description: 'Called when the master checkbox is clicked to select or clear all events.',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof NotificationHeader>;
|
||||
|
||||
|
||||
@@ -10,19 +10,44 @@ interface Props {
|
||||
onToggleAll: () => void;
|
||||
}
|
||||
|
||||
function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
|
||||
function MasterCheckbox({
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
return <input ref={ref} type="checkbox" aria-label={t('pages.clients.selectAll')} checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
aria-label={t('pages.clients.selectAll')}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {
|
||||
export function NotificationHeader({
|
||||
count,
|
||||
total,
|
||||
allSelected,
|
||||
indeterminate,
|
||||
onToggleAll,
|
||||
}: Props) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<Tag>{count}/{total}</Tag>
|
||||
<Tag>
|
||||
{count}/{total}
|
||||
</Tag>
|
||||
<MasterCheckbox checked={allSelected} indeterminate={indeterminate} onChange={onToggleAll} />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,15 @@ function OutboundGroup() {
|
||||
<NotificationCard
|
||||
icon={<CloudServerOutlined />}
|
||||
title="Outbound"
|
||||
extra={<NotificationHeader count={1} total={2} allSelected={false} indeterminate onToggleAll={noop} />}
|
||||
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} />
|
||||
@@ -35,7 +43,15 @@ function XrayGroup() {
|
||||
<NotificationCard
|
||||
icon={<ThunderboltOutlined />}
|
||||
title="Xray"
|
||||
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
|
||||
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} />
|
||||
@@ -49,7 +65,15 @@ function NodeGroup() {
|
||||
<NotificationCard
|
||||
icon={<DesktopOutlined />}
|
||||
title="Nodes"
|
||||
extra={<NotificationHeader count={0} total={2} allSelected={false} indeterminate={false} onToggleAll={noop} />}
|
||||
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} />
|
||||
@@ -64,14 +88,36 @@ function SystemGroup() {
|
||||
<NotificationCard
|
||||
icon={<DashboardOutlined />}
|
||||
title="System"
|
||||
extra={<NotificationHeader count={2} total={2} allSelected indeterminate={false} onToggleAll={noop} />}
|
||||
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 }} />
|
||||
<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 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={90}
|
||||
aria-label="Memory usage threshold percent"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</NotificationEvent>
|
||||
</Space>
|
||||
</NotificationCard>
|
||||
@@ -83,7 +129,15 @@ function SecurityGroup() {
|
||||
<NotificationCard
|
||||
icon={<SafetyOutlined />}
|
||||
title="Security"
|
||||
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
|
||||
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} />
|
||||
|
||||
@@ -6,7 +6,13 @@ interface Props {
|
||||
|
||||
export function NotificationLayout({ children }: Props) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,8 +18,14 @@ const meta = {
|
||||
},
|
||||
},
|
||||
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.' },
|
||||
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>;
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { InputNumber } from 'antd';
|
||||
import { CloudServerOutlined, ThunderboltOutlined, DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
ThunderboltOutlined,
|
||||
DesktopOutlined,
|
||||
DashboardOutlined,
|
||||
SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { NotificationLayout } from './NotificationLayout';
|
||||
import { NotificationGroup } from './NotificationGroup';
|
||||
@@ -15,7 +21,15 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
label: 'eventOutboundDown',
|
||||
settingKey: 'outboundDownThreshold',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||
@@ -24,9 +38,7 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
{
|
||||
icon: <ThunderboltOutlined />,
|
||||
title: 'eventGroupXray',
|
||||
events: [
|
||||
{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' },
|
||||
],
|
||||
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
|
||||
},
|
||||
{
|
||||
icon: <DesktopOutlined />,
|
||||
@@ -45,7 +57,15 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
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 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -53,7 +73,15 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
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 }} />
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -61,9 +89,7 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
{
|
||||
icon: <SafetyOutlined />,
|
||||
title: 'eventGroupSecurity',
|
||||
events: [
|
||||
{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' },
|
||||
],
|
||||
events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -74,12 +100,15 @@ interface Props {
|
||||
|
||||
export function TelegramNotifications({ allSetting, updateSetting }: Props) {
|
||||
const events = allSetting.tgEnabledEvents || '';
|
||||
const selected = events ? events.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const selected = events
|
||||
? events
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
function toggle(key: string) {
|
||||
const next = selected.includes(key)
|
||||
? selected.filter((e) => e !== key)
|
||||
: [...selected, key];
|
||||
const next = selected.includes(key) ? selected.filter((e) => e !== key) : [...selected, key];
|
||||
updateSetting({ tgEnabledEvents: next.join(',') });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ export interface NotificationEventConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
settingKey: string;
|
||||
extra?: (props: { value: number; onChange: (v: number | null) => void; ariaLabel: string }) => ReactNode;
|
||||
extra?: (props: {
|
||||
value: number;
|
||||
onChange: (v: number | null) => void;
|
||||
ariaLabel: string;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
export interface NotificationGroupConfig {
|
||||
|
||||
@@ -18,7 +18,9 @@ const meta = {
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
when: { description: 'Children mount the first time this becomes true and stay mounted afterwards.' },
|
||||
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.' },
|
||||
},
|
||||
@@ -53,7 +55,8 @@ function OnDemandDemo() {
|
||||
</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.
|
||||
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>
|
||||
);
|
||||
@@ -67,7 +70,13 @@ const xrayConfigSnippet = JSON.stringify(
|
||||
protocol: 'vless',
|
||||
port: 443,
|
||||
settings: {
|
||||
clients: [{ id: 'b831381d-6324-4d53-ad4f-8cda48b30811', email: 'alice@corp.example', flow: 'xtls-rprx-vision' }],
|
||||
clients: [
|
||||
{
|
||||
id: 'b831381d-6324-4d53-ad4f-8cda48b30811',
|
||||
email: 'alice@corp.example',
|
||||
flow: 'xtls-rprx-vision',
|
||||
},
|
||||
],
|
||||
decryption: 'none',
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'reality' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useEffect, useState, type ReactNode } from 'react';
|
||||
import { Suspense, useState, type ReactNode } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
interface LazyMountProps {
|
||||
@@ -13,9 +13,7 @@ interface LazyMountProps {
|
||||
// on heavy list pages to keep the initial bundle small.
|
||||
export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
|
||||
const [mounted, setMounted] = useState(when);
|
||||
useEffect(() => {
|
||||
if (when && !mounted) setMounted(true);
|
||||
}, [when, mounted]);
|
||||
if (when && !mounted) setMounted(true);
|
||||
if (!mounted) return null;
|
||||
return <Suspense fallback={fallback}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import Sparkline from './Sparkline';
|
||||
|
||||
const wave = Array.from({ length: 48 }, (_, i) => 45 + Math.round(28 * Math.sin(i / 4) + (i % 5) * 3));
|
||||
const wave = Array.from(
|
||||
{ length: 48 },
|
||||
(_, i) => 45 + Math.round(28 * Math.sin(i / 4) + (i % 5) * 3),
|
||||
);
|
||||
const inverse = wave.map((v) => Math.max(0, 100 - v));
|
||||
|
||||
const meta = {
|
||||
|
||||
@@ -85,7 +85,10 @@ function hexToRgba(color: string, alpha: number): string {
|
||||
const trimmed = color.trim();
|
||||
const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
|
||||
if (fn) {
|
||||
const parts = fn[1].split(/[,/]\s*|\s+/).filter(Boolean).map(Number);
|
||||
const parts = fn[1]
|
||||
.split(/[,/]\s*|\s+/)
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
|
||||
const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
|
||||
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
|
||||
@@ -94,7 +97,11 @@ function hexToRgba(color: string, alpha: number): string {
|
||||
}
|
||||
let h = trimmed;
|
||||
if (h.startsWith('#')) h = h.slice(1);
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
if (h.length === 3)
|
||||
h = h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
if (h.length !== 6) return trimmed;
|
||||
const int = Number.parseInt(h, 16);
|
||||
if (Number.isNaN(int)) return trimmed;
|
||||
@@ -110,11 +117,14 @@ function cssVar(el: HTMLElement, name: string, fallback: string): string {
|
||||
}
|
||||
|
||||
function parseDash(dash: string, dpr: number): number[] {
|
||||
return dash.trim().split(/\s+/).map((n) => (Number(n) || 0) * dpr);
|
||||
return dash
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((n) => (Number(n) || 0) * dpr);
|
||||
}
|
||||
|
||||
function dprOf(u: uPlot): number {
|
||||
return u.width > 0 ? u.ctx.canvas.width / u.width : (uPlot.pxRatio || 1);
|
||||
return u.width > 0 ? u.ctx.canvas.width / u.width : uPlot.pxRatio || 1;
|
||||
}
|
||||
|
||||
export default function Sparkline(props: SparklineProps) {
|
||||
@@ -258,9 +268,11 @@ export default function Sparkline(props: SparklineProps) {
|
||||
extrema,
|
||||
};
|
||||
const cfgRef = useRef(cfg);
|
||||
cfgRef.current = cfg;
|
||||
const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
|
||||
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
|
||||
useEffect(() => {
|
||||
cfgRef.current = cfg;
|
||||
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
|
||||
});
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotRef = useRef<uPlot | null>(null);
|
||||
@@ -427,7 +439,9 @@ export default function Sparkline(props: SparklineProps) {
|
||||
}
|
||||
const pt = v.points[idx];
|
||||
const fmt = p.tooltipFormatter ?? p.yFormatter ?? ((x: number) => String(x));
|
||||
const label = p.tooltipLabelFormatter ? p.tooltipLabelFormatter(String(pt.label)) : String(pt.label);
|
||||
const label = p.tooltipLabelFormatter
|
||||
? p.tooltipLabelFormatter(String(pt.label))
|
||||
: String(pt.label);
|
||||
const multi = hasSeries2 || hasSeries3;
|
||||
|
||||
tooltipEl.textContent = '';
|
||||
@@ -567,7 +581,11 @@ export default function Sparkline(props: SparklineProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
|
||||
<div
|
||||
className="sparkline-container"
|
||||
role={ariaSummary ? 'img' : undefined}
|
||||
aria-label={ariaSummary || undefined}
|
||||
>
|
||||
{extremaPoints && (
|
||||
<div className="sparkline-extrema" aria-hidden="true">
|
||||
<span className="extrema-item" style={{ color: maxColor }}>
|
||||
@@ -581,7 +599,9 @@ export default function Sparkline(props: SparklineProps) {
|
||||
{showLegend && legendItems.length > 0 && (
|
||||
<div className="sparkline-legend" aria-hidden="true">
|
||||
{legendItems.map((s) => (
|
||||
<span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
|
||||
<span key={s.name} className="extrema-item" style={{ color: s.color }}>
|
||||
● {s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+390
-236
@@ -80,9 +80,16 @@ export interface ClientQueryParams {
|
||||
|
||||
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
||||
const DEFAULT_SUMMARY: ClientsSummary = {
|
||||
total: 0, active: 0,
|
||||
onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
|
||||
online: [], depleted: [], expiring: [], deactive: [],
|
||||
total: 0,
|
||||
active: 0,
|
||||
onlineCount: 0,
|
||||
depletedCount: 0,
|
||||
expiringCount: 0,
|
||||
deactiveCount: 0,
|
||||
online: [],
|
||||
depleted: [],
|
||||
expiring: [],
|
||||
deactive: [],
|
||||
};
|
||||
|
||||
export interface ClientSpeedEntry {
|
||||
@@ -129,7 +136,9 @@ function buildQS(p: ClientQueryParams): string {
|
||||
|
||||
async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
|
||||
const qs = buildQS(params);
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, {
|
||||
silent: true,
|
||||
});
|
||||
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
|
||||
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
|
||||
if (!validated.obj) throw new Error('Empty clients response');
|
||||
@@ -144,7 +153,9 @@ async function fetchInboundOptions(): Promise<InboundOption[]> {
|
||||
}
|
||||
|
||||
async function fetchDefaults(): Promise<Record<string, unknown>> {
|
||||
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
|
||||
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, {
|
||||
silent: true,
|
||||
});
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||
return validated.obj || {};
|
||||
@@ -173,24 +184,25 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const setQuery = useCallback((next: ClientQueryParams) => {
|
||||
setQueryState((prev) => {
|
||||
if (
|
||||
prev
|
||||
&& prev.page === next.page
|
||||
&& prev.pageSize === next.pageSize
|
||||
&& (prev.search ?? '') === (next.search ?? '')
|
||||
&& (prev.filter ?? '') === (next.filter ?? '')
|
||||
&& (prev.protocol ?? '') === (next.protocol ?? '')
|
||||
&& (prev.inbound ?? '') === (next.inbound ?? '')
|
||||
&& (prev.sort ?? '') === (next.sort ?? '')
|
||||
&& (prev.order ?? '') === (next.order ?? '')
|
||||
&& (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
|
||||
&& (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
|
||||
&& (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
|
||||
&& (prev.usageTo ?? 0) === (next.usageTo ?? 0)
|
||||
&& (prev.autoRenew ?? '') === (next.autoRenew ?? '')
|
||||
&& (prev.hasTgId ?? '') === (next.hasTgId ?? '')
|
||||
&& (prev.hasComment ?? '') === (next.hasComment ?? '')
|
||||
&& (prev.group ?? '') === (next.group ?? '')
|
||||
) return prev;
|
||||
prev &&
|
||||
prev.page === next.page &&
|
||||
prev.pageSize === next.pageSize &&
|
||||
(prev.search ?? '') === (next.search ?? '') &&
|
||||
(prev.filter ?? '') === (next.filter ?? '') &&
|
||||
(prev.protocol ?? '') === (next.protocol ?? '') &&
|
||||
(prev.inbound ?? '') === (next.inbound ?? '') &&
|
||||
(prev.sort ?? '') === (next.sort ?? '') &&
|
||||
(prev.order ?? '') === (next.order ?? '') &&
|
||||
(prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0) &&
|
||||
(prev.expiryTo ?? 0) === (next.expiryTo ?? 0) &&
|
||||
(prev.usageFrom ?? 0) === (next.usageFrom ?? 0) &&
|
||||
(prev.usageTo ?? 0) === (next.usageTo ?? 0) &&
|
||||
(prev.autoRenew ?? '') === (next.autoRenew ?? '') &&
|
||||
(prev.hasTgId ?? '') === (next.hasTgId ?? '') &&
|
||||
(prev.hasComment ?? '') === (next.hasComment ?? '') &&
|
||||
(prev.group ?? '') === (next.group ?? '')
|
||||
)
|
||||
return prev;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -251,24 +263,27 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
|
||||
|
||||
const defaults = defaultsQuery.data ?? {};
|
||||
const subSettings: SubSettings = useMemo(() => ({
|
||||
enable: !!defaults.subEnable,
|
||||
subURI: (defaults.subURI as string) || '',
|
||||
subJsonURI: (defaults.subJsonURI as string) || '',
|
||||
subJsonEnable: !!defaults.subJsonEnable,
|
||||
subClashURI: (defaults.subClashURI as string) || '',
|
||||
subClashEnable: !!defaults.subClashEnable,
|
||||
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
|
||||
}), [
|
||||
defaults.subEnable,
|
||||
defaults.subURI,
|
||||
defaults.subJsonURI,
|
||||
defaults.subJsonEnable,
|
||||
defaults.subClashURI,
|
||||
defaults.subClashEnable,
|
||||
defaults.subDomain,
|
||||
defaults.webDomain,
|
||||
]);
|
||||
const subSettings: SubSettings = useMemo(
|
||||
() => ({
|
||||
enable: !!defaults.subEnable,
|
||||
subURI: (defaults.subURI as string) || '',
|
||||
subJsonURI: (defaults.subJsonURI as string) || '',
|
||||
subJsonEnable: !!defaults.subJsonEnable,
|
||||
subClashURI: (defaults.subClashURI as string) || '',
|
||||
subClashEnable: !!defaults.subClashEnable,
|
||||
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
|
||||
}),
|
||||
[
|
||||
defaults.subEnable,
|
||||
defaults.subURI,
|
||||
defaults.subJsonURI,
|
||||
defaults.subJsonEnable,
|
||||
defaults.subClashURI,
|
||||
defaults.subClashEnable,
|
||||
defaults.subDomain,
|
||||
defaults.webDomain,
|
||||
],
|
||||
);
|
||||
|
||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||
const tgBotEnable = !!defaults.tgBotEnable;
|
||||
@@ -284,17 +299,14 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
||||
|
||||
const invalidateAll = useCallback(
|
||||
() => {
|
||||
markLocalInvalidate();
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
||||
]);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
const invalidateAll = useCallback(() => {
|
||||
markLocalInvalidate();
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
||||
]);
|
||||
}, [queryClient]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await invalidateAll();
|
||||
@@ -311,25 +323,33 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: unknown) =>
|
||||
HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkAddToGroupMut = useMutation({
|
||||
mutationFn: (body: { emails: string[]; group: string }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkRemoveFromGroupMut = useMutation({
|
||||
mutationFn: (body: { emails: string[] }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
|
||||
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
@@ -339,15 +359,22 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
|
||||
return HttpUtil.post(url);
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkDeleteMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise<Msg<BulkDeleteResult>> => {
|
||||
mutationFn: async (payload: {
|
||||
emails: string[];
|
||||
keepTraffic?: boolean;
|
||||
}): Promise<Msg<BulkDeleteResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkDel', payload, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkDeleteResultSchema, 'clients/bulkDel');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkCreateMut = useMutation({
|
||||
@@ -355,70 +382,121 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkCreate', payloads, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkCreateResultSchema, 'clients/bulkCreate');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkAdjustMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number; flow: string }): Promise<Msg<BulkAdjustResult>> => {
|
||||
mutationFn: async (payload: {
|
||||
emails: string[];
|
||||
addDays: number;
|
||||
addBytes: number;
|
||||
flow: string;
|
||||
}): Promise<Msg<BulkAdjustResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkSetEnableMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; enable: boolean }): Promise<Msg<BulkSetEnableResult>> => {
|
||||
const path = payload.enable ? '/panel/api/clients/bulkEnable' : '/panel/api/clients/bulkDisable';
|
||||
mutationFn: async (payload: {
|
||||
emails: string[];
|
||||
enable: boolean;
|
||||
}): Promise<Msg<BulkSetEnableResult>> => {
|
||||
const path = payload.enable
|
||||
? '/panel/api/clients/bulkEnable'
|
||||
: '/panel/api/clients/bulkDisable';
|
||||
const raw = await HttpUtil.post(path, { emails: payload.emails }, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkSetEnableResultSchema, payload.enable ? 'clients/bulkEnable' : 'clients/bulkDisable');
|
||||
return parseMsg(
|
||||
raw,
|
||||
BulkSetEnableResultSchema,
|
||||
payload.enable ? 'clients/bulkEnable' : 'clients/bulkDisable',
|
||||
);
|
||||
},
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const attachMut = useMutation({
|
||||
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
HttpUtil.post(
|
||||
`/panel/api/clients/${encodeURIComponent(email)}/attach`,
|
||||
{ inboundIds },
|
||||
{ ...JSON_HEADERS, silentSuccess: true },
|
||||
),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const setExternalLinksMut = useMutation({
|
||||
mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, { ...JSON_HEADERS, silentSuccess: true }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
HttpUtil.post(
|
||||
`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`,
|
||||
{ externalLinks },
|
||||
{ ...JSON_HEADERS, silentSuccess: true },
|
||||
),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkAttachMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
|
||||
mutationFn: async (payload: {
|
||||
emails: string[];
|
||||
inboundIds: number[];
|
||||
}): Promise<Msg<BulkAttachResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkAttachResultSchema, 'clients/bulkAttach');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const detachMut = useMutation({
|
||||
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
HttpUtil.post(
|
||||
`/panel/api/clients/${encodeURIComponent(email)}/detach`,
|
||||
{ inboundIds },
|
||||
{ ...JSON_HEADERS, silentSuccess: true },
|
||||
),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const bulkDetachMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
|
||||
mutationFn: async (payload: {
|
||||
emails: string[];
|
||||
inboundIds: number[];
|
||||
}): Promise<Msg<BulkDetachResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkDetachResultSchema, 'clients/bulkDetach');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const resetTrafficMut = useMutation({
|
||||
mutationFn: (email: string) =>
|
||||
HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const resetAllTrafficsMut = useMutation({
|
||||
mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const delDepletedMut = useMutation({
|
||||
@@ -426,7 +504,9 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
|
||||
return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const delOrphansMut = useMutation({
|
||||
@@ -434,7 +514,9 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/delOrphans');
|
||||
return parseMsg(raw, DelDepletedResultSchema, 'clients/delOrphans');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const importClientsMut = useMutation({
|
||||
@@ -442,76 +524,137 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/import', { data }, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkCreateResultSchema, 'clients/import');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
|
||||
const update = useCallback((email: string, client: unknown) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return updateMut.mutateAsync({ email, client });
|
||||
}, [updateMut]);
|
||||
const remove = useCallback((email: string, keepTraffic = false) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return removeMut.mutateAsync({ email, keepTraffic });
|
||||
}, [removeMut]);
|
||||
const bulkDelete = useCallback((emails: string[], keepTraffic = false) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
|
||||
return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
|
||||
}, [bulkDeleteMut]);
|
||||
const bulkCreate = useCallback((payloads: unknown[]) => {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
|
||||
return bulkCreateMut.mutateAsync(payloads);
|
||||
}, [bulkCreateMut]);
|
||||
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number, flow = '') => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
|
||||
}, [bulkAdjustMut]);
|
||||
const bulkEnable = useCallback((emails: string[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
|
||||
return bulkSetEnableMut.mutateAsync({ emails, enable: true });
|
||||
}, [bulkSetEnableMut]);
|
||||
const bulkDisable = useCallback((emails: string[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
|
||||
return bulkSetEnableMut.mutateAsync({ emails, enable: false });
|
||||
}, [bulkSetEnableMut]);
|
||||
const bulkAddToGroup = useCallback((emails: string[], group: string) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkAddToGroupMut.mutateAsync({ emails, group });
|
||||
}, [bulkAddToGroupMut]);
|
||||
const bulkRemoveFromGroup = useCallback((emails: string[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkRemoveFromGroupMut.mutateAsync({ emails });
|
||||
}, [bulkRemoveFromGroupMut]);
|
||||
const attach = useCallback((email: string, inboundIds: number[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return attachMut.mutateAsync({ email, inboundIds });
|
||||
}, [attachMut]);
|
||||
const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return setExternalLinksMut.mutateAsync({ email, externalLinks });
|
||||
}, [setExternalLinksMut]);
|
||||
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||
return bulkAttachMut.mutateAsync({ emails, inboundIds });
|
||||
}, [bulkAttachMut]);
|
||||
const detach = useCallback((email: string, inboundIds: number[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return detachMut.mutateAsync({ email, inboundIds });
|
||||
}, [detachMut]);
|
||||
const bulkDetach = useCallback((emails: string[], inboundIds: number[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
|
||||
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
|
||||
return bulkDetachMut.mutateAsync({ emails, inboundIds });
|
||||
}, [bulkDetachMut]);
|
||||
const resetTraffic = useCallback((client: ClientRecord) => {
|
||||
if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return resetTrafficMut.mutateAsync(client.email);
|
||||
}, [resetTrafficMut]);
|
||||
const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
|
||||
const update = useCallback(
|
||||
(email: string, client: unknown) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return updateMut.mutateAsync({ email, client });
|
||||
},
|
||||
[updateMut],
|
||||
);
|
||||
const remove = useCallback(
|
||||
(email: string, keepTraffic = false) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return removeMut.mutateAsync({ email, keepTraffic });
|
||||
},
|
||||
[removeMut],
|
||||
);
|
||||
const bulkDelete = useCallback(
|
||||
(emails: string[], keepTraffic = false) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0)
|
||||
return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
|
||||
return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
|
||||
},
|
||||
[bulkDeleteMut],
|
||||
);
|
||||
const bulkCreate = useCallback(
|
||||
(payloads: unknown[]) => {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0)
|
||||
return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
|
||||
return bulkCreateMut.mutateAsync(payloads);
|
||||
},
|
||||
[bulkCreateMut],
|
||||
);
|
||||
const bulkAdjust = useCallback(
|
||||
(emails: string[], addDays: number, addBytes: number, flow = '') => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
|
||||
},
|
||||
[bulkAdjustMut],
|
||||
);
|
||||
const bulkEnable = useCallback(
|
||||
(emails: string[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0)
|
||||
return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
|
||||
return bulkSetEnableMut.mutateAsync({ emails, enable: true });
|
||||
},
|
||||
[bulkSetEnableMut],
|
||||
);
|
||||
const bulkDisable = useCallback(
|
||||
(emails: string[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0)
|
||||
return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
|
||||
return bulkSetEnableMut.mutateAsync({ emails, enable: false });
|
||||
},
|
||||
[bulkSetEnableMut],
|
||||
);
|
||||
const bulkAddToGroup = useCallback(
|
||||
(emails: string[], group: string) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkAddToGroupMut.mutateAsync({ emails, group });
|
||||
},
|
||||
[bulkAddToGroupMut],
|
||||
);
|
||||
const bulkRemoveFromGroup = useCallback(
|
||||
(emails: string[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkRemoveFromGroupMut.mutateAsync({ emails });
|
||||
},
|
||||
[bulkRemoveFromGroupMut],
|
||||
);
|
||||
const attach = useCallback(
|
||||
(email: string, inboundIds: number[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return attachMut.mutateAsync({ email, inboundIds });
|
||||
},
|
||||
[attachMut],
|
||||
);
|
||||
const setExternalLinks = useCallback(
|
||||
(email: string, externalLinks: ExternalLinkInput[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return setExternalLinksMut.mutateAsync({ email, externalLinks });
|
||||
},
|
||||
[setExternalLinksMut],
|
||||
);
|
||||
const bulkAttach = useCallback(
|
||||
(emails: string[], inboundIds: number[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0)
|
||||
return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||
if (!Array.isArray(inboundIds) || inboundIds.length === 0)
|
||||
return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||
return bulkAttachMut.mutateAsync({ emails, inboundIds });
|
||||
},
|
||||
[bulkAttachMut],
|
||||
);
|
||||
const detach = useCallback(
|
||||
(email: string, inboundIds: number[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return detachMut.mutateAsync({ email, inboundIds });
|
||||
},
|
||||
[detachMut],
|
||||
);
|
||||
const bulkDetach = useCallback(
|
||||
(emails: string[], inboundIds: number[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0)
|
||||
return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
|
||||
if (!Array.isArray(inboundIds) || inboundIds.length === 0)
|
||||
return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
|
||||
return bulkDetachMut.mutateAsync({ emails, inboundIds });
|
||||
},
|
||||
[bulkDetachMut],
|
||||
);
|
||||
const resetTraffic = useCallback(
|
||||
(client: ClientRecord) => {
|
||||
if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return resetTrafficMut.mutateAsync(client.email);
|
||||
},
|
||||
[resetTrafficMut],
|
||||
);
|
||||
const resetAllTraffics = useCallback(
|
||||
() => resetAllTrafficsMut.mutateAsync(),
|
||||
[resetAllTrafficsMut],
|
||||
);
|
||||
const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
|
||||
const delOrphans = useCallback(() => delOrphansMut.mutateAsync(), [delOrphansMut]);
|
||||
const importClients = useCallback((data: string) => importClientsMut.mutateAsync(data), [importClientsMut]);
|
||||
const importClients = useCallback(
|
||||
(data: string) => importClientsMut.mutateAsync(data),
|
||||
[importClientsMut],
|
||||
);
|
||||
// Fetch the exported clients so the page can show them in a CodeMirror viewer
|
||||
// (Copy / Download), rather than triggering an immediate browser download.
|
||||
const exportClients = useCallback(async (): Promise<unknown[] | null> => {
|
||||
@@ -520,104 +663,115 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
return Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}, []);
|
||||
|
||||
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
|
||||
if (!client?.email) return null;
|
||||
const full = await hydrate(client.email);
|
||||
const base = full?.client;
|
||||
if (!base) return null;
|
||||
const payload: Record<string, unknown> = {
|
||||
email: base.email,
|
||||
subId: base.subId,
|
||||
id: base.uuid,
|
||||
password: base.password,
|
||||
auth: base.auth,
|
||||
flow: base.flow || '',
|
||||
security: base.security || 'auto',
|
||||
totalGB: base.totalGB || 0,
|
||||
expiryTime: base.expiryTime || 0,
|
||||
limitIp: base.limitIp || 0,
|
||||
limitHwid: base.limitHwid || 0,
|
||||
tgId: Number(base.tgId) || 0,
|
||||
reset: Number(base.reset) || 0,
|
||||
resetDay: Number(base.resetDay) || 0,
|
||||
resetMax: Number(base.resetMax) || 0,
|
||||
group: base.group || '',
|
||||
comment: base.comment || '',
|
||||
enable: !!enable,
|
||||
};
|
||||
if (base.reverse?.tag) {
|
||||
payload.reverse = { tag: base.reverse.tag };
|
||||
}
|
||||
return update(client.email, payload);
|
||||
}, [hydrate, update]);
|
||||
const setEnable = useCallback(
|
||||
async (client: ClientRecord, enable: boolean) => {
|
||||
if (!client?.email) return null;
|
||||
const full = await hydrate(client.email);
|
||||
const base = full?.client;
|
||||
if (!base) return null;
|
||||
const payload: Record<string, unknown> = {
|
||||
email: base.email,
|
||||
subId: base.subId,
|
||||
id: base.uuid,
|
||||
password: base.password,
|
||||
auth: base.auth,
|
||||
flow: base.flow || '',
|
||||
security: base.security || 'auto',
|
||||
totalGB: base.totalGB || 0,
|
||||
expiryTime: base.expiryTime || 0,
|
||||
limitIp: base.limitIp || 0,
|
||||
limitHwid: base.limitHwid || 0,
|
||||
tgId: Number(base.tgId) || 0,
|
||||
reset: Number(base.reset) || 0,
|
||||
resetDay: Number(base.resetDay) || 0,
|
||||
resetMax: Number(base.resetMax) || 0,
|
||||
group: base.group || '',
|
||||
comment: base.comment || '',
|
||||
enable: !!enable,
|
||||
};
|
||||
if (base.reverse?.tag) {
|
||||
payload.reverse = { tag: base.reverse.tag };
|
||||
}
|
||||
return update(client.email, payload);
|
||||
},
|
||||
[hydrate, update],
|
||||
);
|
||||
|
||||
// WS-driven in-place merges. Page wires these via useWebSocket; the bridge
|
||||
// covers coarse 'invalidate' and 'inbounds' events centrally.
|
||||
const queryRef = useRef(query);
|
||||
queryRef.current = query;
|
||||
useEffect(() => {
|
||||
queryRef.current = query;
|
||||
});
|
||||
|
||||
const applyTrafficEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as {
|
||||
onlineClients?: string[];
|
||||
clientTraffics?: { email: string; up: number; down: number }[];
|
||||
};
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
||||
}
|
||||
if (Array.isArray(p.clientTraffics)) {
|
||||
// Xray reports a row per client whether or not it moved a byte, so most of
|
||||
// this map used to be zeros. A missing entry and a zero entry render
|
||||
// identically (isActiveSpeed treats both as inactive), so the zeros are
|
||||
// dropped and an unchanged result returns the previous object — which lets
|
||||
// React bail out of the update instead of re-rendering the table.
|
||||
const next: Record<string, ClientSpeedEntry> = {};
|
||||
for (const ct of p.clientTraffics) {
|
||||
if (!ct || !ct.email) continue;
|
||||
const up = ct.up || 0;
|
||||
const down = ct.down || 0;
|
||||
if (up === 0 && down === 0) continue;
|
||||
next[ct.email] = {
|
||||
up: up / TRAFFIC_POLL_INTERVAL_S,
|
||||
down: down / TRAFFIC_POLL_INTERVAL_S,
|
||||
};
|
||||
const applyTrafficEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as {
|
||||
onlineClients?: string[];
|
||||
clientTraffics?: { email: string; up: number; down: number }[];
|
||||
};
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
||||
}
|
||||
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
|
||||
}
|
||||
}, [queryClient]);
|
||||
if (Array.isArray(p.clientTraffics)) {
|
||||
// Xray reports a row per client whether or not it moved a byte, so most of
|
||||
// this map used to be zeros. A missing entry and a zero entry render
|
||||
// identically (isActiveSpeed treats both as inactive), so the zeros are
|
||||
// dropped and an unchanged result returns the previous object — which lets
|
||||
// React bail out of the update instead of re-rendering the table.
|
||||
const next: Record<string, ClientSpeedEntry> = {};
|
||||
for (const ct of p.clientTraffics) {
|
||||
if (!ct || !ct.email) continue;
|
||||
const up = ct.up || 0;
|
||||
const down = ct.down || 0;
|
||||
if (up === 0 && down === 0) continue;
|
||||
next[ct.email] = {
|
||||
up: up / TRAFFIC_POLL_INTERVAL_S,
|
||||
down: down / TRAFFIC_POLL_INTERVAL_S,
|
||||
};
|
||||
}
|
||||
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const applyClientStatsEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { clients?: ClientStatRow[] };
|
||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||
const active = queryRef.current;
|
||||
if (!active) return;
|
||||
const byEmail = new Map<string, ClientTraffic>();
|
||||
for (const row of p.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
|
||||
if (!prev) return prev;
|
||||
let touched = false;
|
||||
const next = prev.items.slice();
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged: ClientTraffic = { ...(row.traffic || {}) };
|
||||
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||
next[i] = { ...row, traffic: merged };
|
||||
touched = true;
|
||||
const applyClientStatsEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { clients?: ClientStatRow[] };
|
||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||
const active = queryRef.current;
|
||||
if (!active) return;
|
||||
const byEmail = new Map<string, ClientTraffic>();
|
||||
for (const row of p.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
if (!touched) return prev;
|
||||
return { ...prev, items: next };
|
||||
});
|
||||
}, [queryClient]);
|
||||
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
|
||||
if (!prev) return prev;
|
||||
let touched = false;
|
||||
const next = prev.items.slice();
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged: ClientTraffic = { ...(row.traffic || {}) };
|
||||
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||
next[i] = { ...row, traffic: merged };
|
||||
touched = true;
|
||||
}
|
||||
if (!touched) return prev;
|
||||
return { ...prev, items: next };
|
||||
});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
queryRef.current = query;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
export const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
@@ -11,17 +11,21 @@ export const MOBILE_BREAKPOINT_PX = 768;
|
||||
*/
|
||||
export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
|
||||
const query = `(max-width: ${breakpoint}px)`;
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() =>
|
||||
typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
const mql = window.matchMedia(query);
|
||||
mql.addEventListener('change', onStoreChange);
|
||||
return () => mql.removeEventListener('change', onStoreChange);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mql.addEventListener('change', onChange);
|
||||
setIsMobile(mql.matches);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, [query]);
|
||||
const isMobile = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(query).matches,
|
||||
() => false,
|
||||
);
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
|
||||
export function useServerDraft<T>(
|
||||
server: T | undefined,
|
||||
clone: (value: T) => T,
|
||||
equals: (left: T, right: T) => boolean,
|
||||
) {
|
||||
const cloneRef = useRef(clone);
|
||||
const equalsRef = useRef(equals);
|
||||
cloneRef.current = clone;
|
||||
equalsRef.current = equals;
|
||||
useEffect(() => {
|
||||
cloneRef.current = clone;
|
||||
});
|
||||
|
||||
const [draft, setDraft] = useState<T | undefined>();
|
||||
const [baseline, setBaseline] = useState<T | undefined>();
|
||||
const draftRef = useRef(draft);
|
||||
const baselineRef = useRef(baseline);
|
||||
draftRef.current = draft;
|
||||
baselineRef.current = baseline;
|
||||
const [syncedServer, setSyncedServer] = useState<T | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (server === undefined) return;
|
||||
const currentDraft = draftRef.current;
|
||||
const currentBaseline = baselineRef.current;
|
||||
const isDirty = currentDraft !== undefined
|
||||
&& (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
|
||||
setBaseline(server);
|
||||
if (isDirty && !equalsRef.current(currentDraft, server)) return;
|
||||
setDraft(cloneRef.current(server));
|
||||
}, [server]);
|
||||
const isDirty = draft !== undefined && (baseline === undefined || !equals(draft, baseline));
|
||||
|
||||
// Adopting the server value during render (not in an effect) keeps the
|
||||
// returned draft and isDirty consistent within the very first render.
|
||||
if (server !== syncedServer) {
|
||||
setSyncedServer(server);
|
||||
if (server !== undefined) {
|
||||
setBaseline(server);
|
||||
const keepLocalEdits = isDirty && !equals(draft as T, server);
|
||||
if (!keepLocalEdits) setDraft(clone(server));
|
||||
}
|
||||
}
|
||||
|
||||
const markSaved = useCallback((value: T) => {
|
||||
setBaseline(cloneRef.current(value));
|
||||
}, []);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
|
||||
[baseline, draft],
|
||||
);
|
||||
|
||||
return { draft, setDraft, isDirty, markSaved };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user