mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint
TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.
Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.
Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.
oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.
The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.
Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.
Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
cast, which hid the optional chain from ESLint and would throw on a
null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
accessible but oxlint cannot evaluate it, so it gets a scoped disable.
* chore(docs): replace Prettier with oxfmt
oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.
The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)
The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.
.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.
oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.
* style(frontend): adopt oxfmt and format src
frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.
Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.
Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.
Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.
* ci: enforce formatting in CI and make verify
Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.
Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.
Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.
No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.
* ci: trigger CI on Makefile changes
The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.
* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components
`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.
Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.
Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.
Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.
* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard
Addresses the review on #6262.
The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.
The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.
The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.
Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
hand-written lint logic in the repo is no longer the least covered
file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
CI gate in this PR while the hook only ran the linter, so a commit
could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
and make msw-worker-check byte-compare stay safe even if oxfmt is
invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
both accept JSONC, so relocating them was unnecessary.
Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
This commit is contained in:
@@ -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
+1280
-2598
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -12,7 +12,10 @@
|
||||
"dev": "vite",
|
||||
"build": "npm run gen:api && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"lint": "oxlint 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,7 +27,10 @@
|
||||
"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",
|
||||
@@ -50,7 +56,6 @@
|
||||
"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",
|
||||
@@ -63,25 +68,20 @@
|
||||
"@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",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.3.0",
|
||||
"msw": "^2.15.0",
|
||||
"oxfmt": "0.63.0",
|
||||
"oxlint": "1.78.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"playwright": "^1.62.1",
|
||||
"storybook": "^10.5.7",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"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}
|
||||
|
||||
@@ -71,7 +71,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.' },
|
||||
},
|
||||
|
||||
@@ -22,7 +22,15 @@ 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('');
|
||||
@@ -55,37 +63,41 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||
title={title}
|
||||
onCancel={onClose}
|
||||
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) => {
|
||||
@@ -351,7 +752,7 @@ function BrowserDemo(props: GeoBrowserModalProps) {
|
||||
useEffect(() => setOpen(props.open), [props.open]);
|
||||
useEffect(() => setValue(props.value), [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 +799,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 { 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('');
|
||||
@@ -120,7 +141,10 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
seededFilesRef.current.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]);
|
||||
|
||||
@@ -149,7 +173,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 +183,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 +203,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 +228,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 +243,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 +260,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 (
|
||||
@@ -245,7 +281,14 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
|
||||
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 +296,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 +324,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 +343,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 +359,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 +421,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 +431,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 +442,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[] {
|
||||
@@ -180,7 +218,7 @@ function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoToken
|
||||
const [current, setCurrent] = useState(value);
|
||||
useEffect(() => setCurrent(value), [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 +247,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 +285,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[]>([]);
|
||||
@@ -72,25 +80,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' },
|
||||
|
||||
@@ -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) {
|
||||
@@ -427,7 +437,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 +579,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 +597,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>
|
||||
)}
|
||||
|
||||
+387
-235
@@ -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,113 @@ 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;
|
||||
|
||||
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,6 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, 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;
|
||||
@@ -17,8 +21,9 @@ export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T,
|
||||
if (server === undefined) return;
|
||||
const currentDraft = draftRef.current;
|
||||
const currentBaseline = baselineRef.current;
|
||||
const isDirty = currentDraft !== undefined
|
||||
&& (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
|
||||
const isDirty =
|
||||
currentDraft !== undefined &&
|
||||
(currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
|
||||
setBaseline(server);
|
||||
if (isDirty && !equalsRef.current(currentDraft, server)) return;
|
||||
setDraft(cloneRef.current(server));
|
||||
|
||||
@@ -26,7 +26,10 @@ function normalizeOutboundTestUrl(url: string) {
|
||||
}
|
||||
|
||||
export function isUdpOutbound(outbound: unknown): boolean {
|
||||
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
||||
const o = outbound as
|
||||
| { protocol?: string; streamSettings?: { network?: string } }
|
||||
| null
|
||||
| undefined;
|
||||
const p = o?.protocol;
|
||||
const n = o?.streamSettings?.network;
|
||||
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
|
||||
@@ -90,7 +93,8 @@ type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
||||
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
||||
if (typeof msg.obj !== 'string')
|
||||
throw new Error('Malformed xray config response: expected string');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(msg.obj);
|
||||
@@ -107,7 +111,9 @@ export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
||||
}
|
||||
|
||||
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
||||
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, { silent: true });
|
||||
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, {
|
||||
silent: true,
|
||||
});
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
||||
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
@@ -137,10 +143,14 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
||||
const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
|
||||
{},
|
||||
);
|
||||
// Subscription outbounds aren't in templateSettings.outbounds, so their test
|
||||
// results are keyed by tag rather than by index.
|
||||
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
||||
const [subscriptionTestStates, setSubscriptionTestStates] = useState<
|
||||
Record<string, OutboundTestState>
|
||||
>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
const syncingRef = useRef(false);
|
||||
@@ -167,8 +177,9 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
setClientReverseTags(obj.clientReverseTags || []);
|
||||
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
||||
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
||||
const isDirty = savedXraySettingRef.current !== xraySettingRef.current
|
||||
|| savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||
const isDirty =
|
||||
savedXraySettingRef.current !== xraySettingRef.current ||
|
||||
savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||
if (isDirty) return;
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(pretty);
|
||||
@@ -242,8 +253,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
});
|
||||
|
||||
const resetTrafficMut = useMutation({
|
||||
mutationFn: (tag: string) =>
|
||||
HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
|
||||
mutationFn: (tag: string) => HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
||||
},
|
||||
@@ -262,9 +272,18 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
},
|
||||
});
|
||||
|
||||
const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
|
||||
const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
|
||||
const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
|
||||
const saveAll = useCallback(async () => {
|
||||
await saveMut.mutateAsync();
|
||||
}, [saveMut]);
|
||||
const resetOutboundsTraffic = useCallback(
|
||||
async (tag: string) => {
|
||||
await resetTrafficMut.mutateAsync(tag);
|
||||
},
|
||||
[resetTrafficMut],
|
||||
);
|
||||
const resetToDefault = useCallback(async () => {
|
||||
await resetDefaultMut.mutateAsync();
|
||||
}, [resetDefaultMut]);
|
||||
|
||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
||||
|
||||
@@ -285,7 +304,9 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
|
||||
if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
|
||||
const list = msg.obj;
|
||||
return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode });
|
||||
return outbounds.map(
|
||||
(_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode },
|
||||
);
|
||||
} catch (e) {
|
||||
return failAll(String(e));
|
||||
}
|
||||
@@ -325,113 +346,134 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
[postOutboundTestBatch],
|
||||
);
|
||||
|
||||
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||
// Template outbounds key their results by index (outboundTestStates);
|
||||
// subscription outbounds aren't in the template, so they key by tag
|
||||
// (subscriptionTestStates). Both go through the same probe endpoint.
|
||||
const templateList = templateSettingsRef.current?.outbounds || [];
|
||||
const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>;
|
||||
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
|
||||
setTestingAll(true);
|
||||
try {
|
||||
type TcpEntry =
|
||||
| { kind: 'tpl'; index: number; outbound: unknown }
|
||||
| { kind: 'sub'; tag: string; outbound: unknown };
|
||||
const tcpQueue: TcpEntry[] = [];
|
||||
// HTTP batches stay homogeneous (all template or all subscription) so a
|
||||
// tag shared between a template and a subscription outbound can't collide
|
||||
// inside one batch, and each batch's results route to one state map.
|
||||
const probeMode = mode === 'real' ? 'real' : 'http';
|
||||
const httpTplQueue: { index: number; outbound: unknown }[] = [];
|
||||
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
|
||||
const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
|
||||
const proto = ob?.protocol;
|
||||
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
|
||||
// freedom ("direct") and dns aren't proxies — skip them in every mode.
|
||||
if (proto === 'freedom' || proto === 'dns') return;
|
||||
if (kind === 'sub' && !tag) return;
|
||||
const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
|
||||
if (kind === 'tpl') {
|
||||
if (toHttp) httpTplQueue.push({ index, outbound: ob });
|
||||
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
|
||||
} else if (toHttp) {
|
||||
httpSubQueue.push({ tag, outbound: ob });
|
||||
} else {
|
||||
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
|
||||
}
|
||||
};
|
||||
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
|
||||
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
|
||||
|
||||
// TCP probes are dial-only and cheap server-side; per-item requests
|
||||
// keep results landing one by one, each routed to its own state map.
|
||||
const runTcpLane = async () => {
|
||||
const queue = [...tcpQueue];
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode);
|
||||
else await testOutbound(item.index, item.outbound, mode);
|
||||
const testAllOutbounds = useCallback(
|
||||
async (mode = 'tcp') => {
|
||||
// Template outbounds key their results by index (outboundTestStates);
|
||||
// subscription outbounds aren't in the template, so they key by tag
|
||||
// (subscriptionTestStates). Both go through the same probe endpoint.
|
||||
const templateList = templateSettingsRef.current?.outbounds || [];
|
||||
const subList = (subscriptionOutboundsRef.current || []) as Array<{
|
||||
tag?: string;
|
||||
protocol?: string;
|
||||
}>;
|
||||
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
|
||||
setTestingAll(true);
|
||||
try {
|
||||
type TcpEntry =
|
||||
| { kind: 'tpl'; index: number; outbound: unknown }
|
||||
| { kind: 'sub'; tag: string; outbound: unknown };
|
||||
const tcpQueue: TcpEntry[] = [];
|
||||
// HTTP batches stay homogeneous (all template or all subscription) so a
|
||||
// tag shared between a template and a subscription outbound can't collide
|
||||
// inside one batch, and each batch's results route to one state map.
|
||||
const probeMode = mode === 'real' ? 'real' : 'http';
|
||||
const httpTplQueue: { index: number; outbound: unknown }[] = [];
|
||||
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
|
||||
const enqueue = (
|
||||
ob: { tag?: string; protocol?: string },
|
||||
kind: 'tpl' | 'sub',
|
||||
index: number,
|
||||
tag: string,
|
||||
) => {
|
||||
const proto = ob?.protocol;
|
||||
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
|
||||
// freedom ("direct") and dns aren't proxies — skip them in every mode.
|
||||
if (proto === 'freedom' || proto === 'dns') return;
|
||||
if (kind === 'sub' && !tag) return;
|
||||
const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
|
||||
if (kind === 'tpl') {
|
||||
if (toHttp) httpTplQueue.push({ index, outbound: ob });
|
||||
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
|
||||
} else if (toHttp) {
|
||||
httpSubQueue.push({ tag, outbound: ob });
|
||||
} else {
|
||||
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||
};
|
||||
// HTTP probes go out as chunked batches — one temp xray spawn per
|
||||
// chunk instead of one per outbound, with results landing per chunk.
|
||||
const runTplHttpLane = async () => {
|
||||
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.index] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
const runSubHttpLane = async () => {
|
||||
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.tag] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
// HTTP batches must not overlap: the backend serialises them with a
|
||||
// non-blocking lock and rejects a second concurrent batch ("Another
|
||||
// outbound test is already running"). Run the template and subscription
|
||||
// HTTP lanes one after the other; TCP probes don't take that lock, so
|
||||
// they still run alongside.
|
||||
const runHttpLane = async () => {
|
||||
await runTplHttpLane();
|
||||
await runSubHttpLane();
|
||||
};
|
||||
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
||||
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
|
||||
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
|
||||
|
||||
const saveDisabled = savedXraySetting === xraySetting
|
||||
&& savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
||||
// TCP probes are dial-only and cheap server-side; per-item requests
|
||||
// keep results landing one by one, each routed to its own state map.
|
||||
const runTcpLane = async () => {
|
||||
const queue = [...tcpQueue];
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
if (item.kind === 'sub')
|
||||
await testSubscriptionOutbound(item.tag, item.outbound, mode);
|
||||
else await testOutbound(item.index, item.outbound, mode);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||
};
|
||||
// HTTP probes go out as chunked batches — one temp xray spawn per
|
||||
// chunk instead of one per outbound, with results landing per chunk.
|
||||
const runTplHttpLane = async () => {
|
||||
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk)
|
||||
next[item.index] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(
|
||||
chunk.map((c) => c.outbound),
|
||||
probeMode,
|
||||
);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.index] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
const runSubHttpLane = async () => {
|
||||
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk)
|
||||
next[item.tag] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(
|
||||
chunk.map((c) => c.outbound),
|
||||
probeMode,
|
||||
);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.tag] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
// HTTP batches must not overlap: the backend serialises them with a
|
||||
// non-blocking lock and rejects a second concurrent batch ("Another
|
||||
// outbound test is already running"). Run the template and subscription
|
||||
// HTTP lanes one after the other; TCP probes don't take that lock, so
|
||||
// they still run alongside.
|
||||
const runHttpLane = async () => {
|
||||
await runTplHttpLane();
|
||||
await runSubHttpLane();
|
||||
};
|
||||
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
},
|
||||
[testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch],
|
||||
);
|
||||
|
||||
const saveDisabled =
|
||||
savedXraySetting === xraySetting &&
|
||||
savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
||||
|
||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ function moduleKeyFor(code: string): string {
|
||||
}
|
||||
|
||||
let active: string = LanguageManager.getLanguage();
|
||||
if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
|
||||
if (
|
||||
active !== FALLBACK &&
|
||||
!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))
|
||||
) {
|
||||
active = FALLBACK;
|
||||
}
|
||||
|
||||
@@ -29,7 +32,9 @@ export async function readyI18n() {
|
||||
returnNull: false,
|
||||
});
|
||||
if (active !== FALLBACK) {
|
||||
const loader = lazyModules[moduleKeyFor(active)] as (() => Promise<{ default: Record<string, unknown> }>) | undefined;
|
||||
const loader = lazyModules[moduleKeyFor(active)] as
|
||||
| (() => Promise<{ default: Record<string, unknown> }>)
|
||||
| undefined;
|
||||
if (loader) {
|
||||
const mod = await loader();
|
||||
const messages = (mod.default ?? mod) as Record<string, unknown>;
|
||||
|
||||
@@ -77,7 +77,10 @@
|
||||
color: var(--ant-color-text-secondary);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.15s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-donate:hover,
|
||||
@@ -104,7 +107,10 @@
|
||||
color: var(--ant-color-text-secondary);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.15s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-docs:hover,
|
||||
@@ -132,7 +138,10 @@
|
||||
color: var(--ant-color-text-secondary);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.15s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle:hover,
|
||||
@@ -251,7 +260,10 @@
|
||||
color: var(--ant-color-text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.15s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-pin:hover,
|
||||
@@ -328,8 +340,8 @@ body.dark .ant-drawer-body {
|
||||
background-color: #15161a;
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] body.dark .ant-drawer-content,
|
||||
html[data-theme="ultra-dark"] body.dark .ant-drawer-body {
|
||||
html[data-theme='ultra-dark'] body.dark .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] body.dark .ant-drawer-body {
|
||||
background-color: #050507;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,19 @@ const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
|
||||
|
||||
let hoveredAcrossRemounts = false;
|
||||
|
||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
|
||||
type IconName =
|
||||
| 'dashboard'
|
||||
| 'inbound'
|
||||
| 'team'
|
||||
| 'groups'
|
||||
| 'setting'
|
||||
| 'tool'
|
||||
| 'cluster'
|
||||
| 'hosts'
|
||||
| 'logout'
|
||||
| 'apidocs'
|
||||
| 'outbound'
|
||||
| 'routing';
|
||||
|
||||
const iconByName: Record<IconName, ComponentType> = {
|
||||
dashboard: DashboardOutlined,
|
||||
@@ -116,7 +128,13 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
function ThemeCycleButton({
|
||||
id,
|
||||
isDark,
|
||||
isUltra,
|
||||
onCycle,
|
||||
ariaLabel,
|
||||
}: {
|
||||
id: string;
|
||||
isDark: boolean;
|
||||
isUltra: boolean;
|
||||
@@ -192,44 +210,70 @@ export default function AppSidebar() {
|
||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||
const panelVersion = window.X_UI_CUR_VER || '';
|
||||
|
||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
|
||||
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
|
||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
||||
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
|
||||
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||
], [t]);
|
||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(
|
||||
() => [
|
||||
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
|
||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
||||
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
|
||||
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
|
||||
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
|
||||
|
||||
const settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => {
|
||||
const children: NonNullable<MenuProps['items']> = [
|
||||
{ key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
|
||||
{ key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
|
||||
{ key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
|
||||
{
|
||||
key: '/settings#general',
|
||||
icon: <SettingOutlined />,
|
||||
label: t('pages.settings.panelSettings'),
|
||||
},
|
||||
{
|
||||
key: '/settings#security',
|
||||
icon: <SafetyOutlined />,
|
||||
label: t('pages.settings.securitySettings'),
|
||||
},
|
||||
{
|
||||
key: '/settings#telegram',
|
||||
icon: <MessageOutlined />,
|
||||
label: t('pages.settings.TGBotSettings'),
|
||||
},
|
||||
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
|
||||
{ key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
|
||||
{
|
||||
key: '/settings#subscription',
|
||||
icon: <CloudServerOutlined />,
|
||||
label: t('pages.settings.subSettings'),
|
||||
},
|
||||
];
|
||||
if (showSubFormats) {
|
||||
children.push({ key: '/settings#subscription-formats', icon: <CodeOutlined />, label: t('menu.subFormats') });
|
||||
children.push({
|
||||
key: '/settings#subscription-formats',
|
||||
icon: <CodeOutlined />,
|
||||
label: t('menu.subFormats'),
|
||||
});
|
||||
}
|
||||
return children;
|
||||
}, [t, showSubFormats]);
|
||||
|
||||
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
|
||||
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
|
||||
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
|
||||
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
|
||||
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
|
||||
], [t]);
|
||||
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
|
||||
() => [
|
||||
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
|
||||
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
|
||||
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
|
||||
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const settingsActive = pathname === '/settings';
|
||||
const xrayActive = pathname === '/xray';
|
||||
@@ -237,7 +281,9 @@ export default function AppSidebar() {
|
||||
? `/settings${hash || '#general'}`
|
||||
: xrayActive
|
||||
? `/xray${hash || '#basic'}`
|
||||
: (pathname === '' ? '/' : pathname);
|
||||
: pathname === ''
|
||||
? '/'
|
||||
: pathname;
|
||||
|
||||
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
|
||||
@@ -247,44 +293,55 @@ export default function AppSidebar() {
|
||||
}
|
||||
}, [openSubmenu]);
|
||||
|
||||
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
|
||||
items.map((tab) => {
|
||||
const Icon = iconByName[tab.icon];
|
||||
if (tab.key === '/settings') {
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
|
||||
const toMenuItems = useCallback(
|
||||
(items: typeof tabs): MenuProps['items'] =>
|
||||
items.map((tab) => {
|
||||
const Icon = iconByName[tab.icon];
|
||||
if (tab.key === '/settings') {
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
|
||||
}
|
||||
if (tab.key === '/xray') {
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
||||
}
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
||||
}),
|
||||
[settingsChildren, xrayChildren],
|
||||
);
|
||||
|
||||
const openLink = useCallback(
|
||||
async (key: string) => {
|
||||
if (key === LOGOUT_KEY) {
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = window.X_UI_BASE_PATH || '/';
|
||||
return;
|
||||
}
|
||||
if (tab.key === '/xray') {
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
||||
navigate(key);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(
|
||||
({ key }) => {
|
||||
openLink(String(key));
|
||||
},
|
||||
[openLink],
|
||||
);
|
||||
|
||||
const cycleTheme = useCallback(
|
||||
(id: string) => {
|
||||
pauseAnimationsUntilLeave(id);
|
||||
if (!isDark) {
|
||||
toggleTheme();
|
||||
if (isUltra) toggleUltra();
|
||||
} else if (!isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
||||
}),
|
||||
[settingsChildren, xrayChildren]);
|
||||
|
||||
const openLink = useCallback(async (key: string) => {
|
||||
if (key === LOGOUT_KEY) {
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = window.X_UI_BASE_PATH || '/';
|
||||
return;
|
||||
}
|
||||
navigate(key);
|
||||
}, [navigate]);
|
||||
|
||||
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
|
||||
openLink(String(key));
|
||||
}, [openLink]);
|
||||
|
||||
const cycleTheme = useCallback((id: string) => {
|
||||
pauseAnimationsUntilLeave(id);
|
||||
if (!isDark) {
|
||||
toggleTheme();
|
||||
if (isUltra) toggleUltra();
|
||||
} else if (!isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||
},
|
||||
[isDark, isUltra, toggleTheme, toggleUltra],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -396,7 +453,10 @@ export default function AppSidebar() {
|
||||
onOpenChange={(keys) => setOpenKeys(keys as string[])}
|
||||
className="drawer-menu drawer-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
onClick={(info) => {
|
||||
onMenuClick(info);
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
@@ -404,7 +464,10 @@ export default function AppSidebar() {
|
||||
selectedKeys={[selectedKey]}
|
||||
className="drawer-menu drawer-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
onClick={(info) => {
|
||||
onMenuClick(info);
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="drawer-footer">
|
||||
<VersionBadge version={panelVersion} />
|
||||
|
||||
@@ -25,10 +25,7 @@ const DISABLED_STROKE = {
|
||||
|
||||
const UNLIMITED_STROKE = '#722ed1';
|
||||
|
||||
export function computeTrafficDisplay(
|
||||
input: TrafficDisplayInput,
|
||||
isDark: boolean,
|
||||
): TrafficDisplay {
|
||||
export function computeTrafficDisplay(input: TrafficDisplayInput, isDark: boolean): TrafficDisplay {
|
||||
const up = input.up || 0;
|
||||
const down = input.down || 0;
|
||||
const used = up + down;
|
||||
|
||||
@@ -46,7 +46,9 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
|
||||
fingerprint: host.fingerprint,
|
||||
alpn: host.alpn && host.alpn.length > 0 ? host.alpn : undefined,
|
||||
pinnedPeerCertSha256:
|
||||
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
|
||||
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0
|
||||
? host.pinnedPeerCertSha256
|
||||
: undefined,
|
||||
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
|
||||
echConfigList: host.echConfigList || undefined,
|
||||
vlessRoute: host.vlessRoute || undefined,
|
||||
|
||||
@@ -52,13 +52,14 @@ export const REMARK_VARIABLES: RemarkVar[] = [
|
||||
{ token: 'SECURITY', group: 'connection', sample: 'TLS' },
|
||||
];
|
||||
|
||||
export const SUBSCRIPTION_METADATA_VARIABLES: RemarkVar[] = REMARK_VARIABLES.filter((v) => (
|
||||
v.token === 'EMAIL'
|
||||
|| v.token === 'ID'
|
||||
|| v.token === 'SHORT_ID'
|
||||
|| v.token === 'TELEGRAM_ID'
|
||||
|| v.token === 'SUB_ID'
|
||||
));
|
||||
export const SUBSCRIPTION_METADATA_VARIABLES: RemarkVar[] = REMARK_VARIABLES.filter(
|
||||
(v) =>
|
||||
v.token === 'EMAIL' ||
|
||||
v.token === 'ID' ||
|
||||
v.token === 'SHORT_ID' ||
|
||||
v.token === 'TELEGRAM_ID' ||
|
||||
v.token === 'SUB_ID',
|
||||
);
|
||||
|
||||
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
|
||||
REMARK_VARIABLES.map((v) => [v.token, v.sample]),
|
||||
@@ -82,7 +83,11 @@ export function hasRemarkTokens(template: string): boolean {
|
||||
* tokens collapse to empty by default; metadata fields can keep unsupported
|
||||
* tokens literal because the backend does the same for backwards compatibility.
|
||||
*/
|
||||
export function previewRemark(template: string, variables: RemarkVar[] = REMARK_VARIABLES, keepUnknown = false): string {
|
||||
export function previewRemark(
|
||||
template: string,
|
||||
variables: RemarkVar[] = REMARK_VARIABLES,
|
||||
keepUnknown = false,
|
||||
): string {
|
||||
if (!hasRemarkTokens(template)) return template;
|
||||
const allowed = new Set(variables.map((v) => v.token));
|
||||
return template.replace(TOKEN_RE, (match, tok: string) => {
|
||||
|
||||
@@ -55,11 +55,27 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.sniffingIpsExcluded')} name={[...name, 'ipsExcluded']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} placeholder="IP/CIDR/geoip:*/ext:*" style={{ width: '100%' }} />
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingIpsExcluded')}
|
||||
name={[...name, 'ipsExcluded']}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.sniffingDomainsExcluded')} name={[...name, 'domainsExcluded']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} placeholder="domain:*/ext:*" style={{ width: '100%' }} />
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingDomainsExcluded')}
|
||||
name={[...name, 'domainsExcluded']}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="domain:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,13 @@ interface FinalMaskFieldProps {
|
||||
|
||||
const EMPTY: FinalMaskStreamSettings = { tcp: [], udp: [] };
|
||||
|
||||
export default function FinalMaskField({ value, onChange, network, protocol, showAll }: FinalMaskFieldProps) {
|
||||
export default function FinalMaskField({
|
||||
value,
|
||||
onChange,
|
||||
network,
|
||||
protocol,
|
||||
showAll,
|
||||
}: FinalMaskFieldProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [initial] = useState(() => value ?? EMPTY);
|
||||
const onChangeRef = useRef(onChange);
|
||||
@@ -41,7 +47,13 @@ export default function FinalMaskField({ value, onChange, network, protocol, sho
|
||||
labelWrap
|
||||
initialValues={{ finalmask: initial }}
|
||||
>
|
||||
<FinalMaskForm name="finalmask" network={network} protocol={protocol} form={form} showAll={showAll} />
|
||||
<FinalMaskForm
|
||||
name="finalmask"
|
||||
network={network}
|
||||
protocol={protocol}
|
||||
form={form}
|
||||
showAll={showAll}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
@@ -9,7 +19,10 @@ import { RandomUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
|
||||
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
|
||||
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}));
|
||||
|
||||
export interface FinalMaskFormProps {
|
||||
name: NamePath;
|
||||
@@ -37,8 +50,11 @@ export function parseGeckoPacketSize(value: unknown): { min: number; max: number
|
||||
const min = Number(match[1]);
|
||||
const max = Number(match[2]);
|
||||
if (
|
||||
!Number.isSafeInteger(min) || !Number.isSafeInteger(max)
|
||||
|| min < GECKO_MIN_PACKET_SIZE || max < min || max > GECKO_MAX_PACKET_SIZE
|
||||
!Number.isSafeInteger(min) ||
|
||||
!Number.isSafeInteger(max) ||
|
||||
min < GECKO_MIN_PACKET_SIZE ||
|
||||
max < min ||
|
||||
max > GECKO_MAX_PACKET_SIZE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -59,9 +75,11 @@ function splitGeckoPacketSize(value: unknown): { min: number | null; max: number
|
||||
|
||||
function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise<void> {
|
||||
if (parseGeckoPacketSize(value)) return Promise.resolve();
|
||||
return Promise.reject(new Error(
|
||||
`Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
|
||||
));
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function asPath(name: NamePath): (string | number)[] {
|
||||
@@ -76,13 +94,21 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
||||
return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' };
|
||||
case 'sudoku':
|
||||
return {
|
||||
password: '', ascii: '', customTable: '', customTables: [],
|
||||
paddingMin: 0, paddingMax: 0,
|
||||
password: '',
|
||||
ascii: '',
|
||||
customTable: '',
|
||||
customTables: [],
|
||||
paddingMin: 0,
|
||||
paddingMax: 0,
|
||||
};
|
||||
case 'header-custom':
|
||||
return { clients: [], servers: [] };
|
||||
case 'xmc':
|
||||
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
|
||||
return {
|
||||
hostname: '',
|
||||
profiles: [defaultXmcProfile()],
|
||||
password: RandomUtil.randomLowerAndNum(16),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -99,7 +125,10 @@ function defaultXmcProfile(): Record<string, unknown> {
|
||||
// legacy username cannot be upgraded automatically — carry it into a profile
|
||||
// stub instead, which keeps the operator's player names visible and leaves the
|
||||
// per-field validators pointing at exactly what still has to be filled in.
|
||||
export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
|
||||
export function migrateXmcSettings(settings: Record<string, unknown>): {
|
||||
next: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
} {
|
||||
const out: Record<string, unknown> = { ...settings };
|
||||
let changed = false;
|
||||
if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
|
||||
@@ -123,7 +152,10 @@ export function migrateXmcSettings(settings: Record<string, unknown>): { next: R
|
||||
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
|
||||
// fallback). Lift any legacy singular value into a one-element array so the
|
||||
// list UI shows it, and drop the singular key so we never emit both.
|
||||
function migrateFragmentSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
|
||||
function migrateFragmentSettings(settings: Record<string, unknown>): {
|
||||
next: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
} {
|
||||
const out: Record<string, unknown> = { ...settings };
|
||||
let changed = false;
|
||||
if (!Array.isArray(out.lengths) && typeof out.length === 'string' && out.length.trim() !== '') {
|
||||
@@ -176,7 +208,11 @@ function defaultUdpClientServerItem(): Record<string, unknown> {
|
||||
|
||||
function defaultNoiseItem(): Record<string, unknown> {
|
||||
return {
|
||||
rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20',
|
||||
rand: '1-8192',
|
||||
randRange: '0-255',
|
||||
type: 'array',
|
||||
packet: [],
|
||||
delay: '10-20',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,7 +235,13 @@ function defaultUdpHop(): Record<string, unknown> {
|
||||
return { ports: '20000-50000', interval: '5-10' };
|
||||
}
|
||||
|
||||
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
||||
export default function FinalMaskForm({
|
||||
name,
|
||||
network,
|
||||
protocol,
|
||||
form,
|
||||
showAll = false,
|
||||
}: FinalMaskFormProps) {
|
||||
const base = asPath(name);
|
||||
|
||||
// Migrate legacy TCP mask shapes once on mount so configs saved before
|
||||
@@ -217,9 +259,8 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
|
||||
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
|
||||
if (!m.settings || typeof m.settings !== 'object') return mask;
|
||||
const settings = m.settings as Record<string, unknown>;
|
||||
const { next: migrated, changed } = m.type === 'fragment'
|
||||
? migrateFragmentSettings(settings)
|
||||
: migrateXmcSettings(settings);
|
||||
const { next: migrated, changed } =
|
||||
m.type === 'fragment' ? migrateFragmentSettings(settings) : migrateXmcSettings(settings);
|
||||
if (!changed) return mask;
|
||||
anyChanged = true;
|
||||
return { ...m, settings: migrated };
|
||||
@@ -244,7 +285,15 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
|
||||
return (
|
||||
<>
|
||||
{showTcp && <TcpMasksList base={base} form={form} />}
|
||||
{showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} isWireguard={isWireguard} network={network} />}
|
||||
{showUdp && (
|
||||
<UdpMasksList
|
||||
base={base}
|
||||
form={form}
|
||||
isHysteria={isHysteria}
|
||||
isWireguard={isWireguard}
|
||||
network={network}
|
||||
/>
|
||||
)}
|
||||
{showQuic && (
|
||||
<>
|
||||
<Form.Item label="QUIC Params">
|
||||
@@ -274,7 +323,9 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })}
|
||||
onClick={() =>
|
||||
add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{fields.map((field, mIdx) => (
|
||||
@@ -294,7 +345,11 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
|
||||
}
|
||||
|
||||
function TcpMaskItem({
|
||||
fieldName, displayIndex, form, listPath, onRemove,
|
||||
fieldName,
|
||||
displayIndex,
|
||||
form,
|
||||
listPath,
|
||||
onRemove,
|
||||
}: {
|
||||
fieldName: number;
|
||||
displayIndex: number;
|
||||
@@ -385,9 +440,15 @@ function TcpMaskItem({
|
||||
if (type === 'sudoku') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
|
||||
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
|
||||
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
|
||||
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
</Form.Item>
|
||||
@@ -423,15 +484,20 @@ function TcpMaskItem({
|
||||
noStyle
|
||||
rules={[{ required: true, message: 'Password is required' }]}
|
||||
>
|
||||
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
|
||||
<Input
|
||||
placeholder="Obfuscation password"
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)}
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
@@ -458,7 +524,9 @@ function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void>
|
||||
function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
||||
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
if (str.length === 0) {
|
||||
return Promise.reject(new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'));
|
||||
return Promise.reject(
|
||||
new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'),
|
||||
);
|
||||
}
|
||||
const min = Number(str.split('-')[0]);
|
||||
if (!Number.isFinite(min) || min <= 0) {
|
||||
@@ -473,7 +541,9 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
||||
function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise<void> {
|
||||
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
if (str.length === 0) {
|
||||
return Promise.reject(new Error("Delay is required — remove the row if you don't want a delay"));
|
||||
return Promise.reject(
|
||||
new Error("Delay is required — remove the row if you don't want a delay"),
|
||||
);
|
||||
}
|
||||
if (!/^\d+(?:-\d+)?$/.test(str)) {
|
||||
return Promise.reject(new Error('Use a delay in ms, e.g. 10 or 10-20'));
|
||||
@@ -486,7 +556,11 @@ function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise<voi
|
||||
// fragment segment N, clamping to the last entry. `minItems` keeps at least
|
||||
// one length row so the config never collapses to an empty (rejected) list.
|
||||
function FragmentRangeList({
|
||||
listName, label, placeholder, validator, minItems = 0,
|
||||
listName,
|
||||
label,
|
||||
placeholder,
|
||||
validator,
|
||||
minItems = 0,
|
||||
}: {
|
||||
listName: (string | number)[];
|
||||
label: string;
|
||||
@@ -500,7 +574,13 @@ function FragmentRangeList({
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={label}>
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add('')}
|
||||
/>
|
||||
</Form.Item>
|
||||
{fields.map((field, idx) => (
|
||||
<Form.Item
|
||||
@@ -511,8 +591,8 @@ function FragmentRangeList({
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
suffix={fields.length > minItems
|
||||
? (
|
||||
suffix={
|
||||
fields.length > minItems ? (
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
@@ -521,8 +601,8 @@ function FragmentRangeList({
|
||||
onClick={() => remove(field.name)}
|
||||
onKeyDown={activateOnKey(() => remove(field.name))}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
@@ -560,7 +640,8 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
|
||||
// Mojang hands the profile UUID back undashed from the session server and
|
||||
// dashed from most other endpoints; xray-core parses either, so accept both
|
||||
// rather than forcing the operator to reformat what they pasted.
|
||||
const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
||||
const XMC_UUID_PATTERN =
|
||||
/^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
||||
const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
|
||||
@@ -626,14 +707,20 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
|
||||
name={[profile.name, 'texturesValue']}
|
||||
rules={[{ required: true, message: 'Textures value is required' }]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder="Base64 value from the session profile"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Textures Signature"
|
||||
name={[profile.name, 'texturesSignature']}
|
||||
rules={[{ required: true, message: 'Textures signature is required' }]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder="Base64 signature from the session profile"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
@@ -644,7 +731,9 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
|
||||
}
|
||||
|
||||
function HeaderCustomGroups({
|
||||
tcpFieldName, form, absoluteSettingsPath,
|
||||
tcpFieldName,
|
||||
form,
|
||||
absoluteSettingsPath,
|
||||
}: {
|
||||
tcpFieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -695,7 +784,12 @@ function HeaderCustomGroups({
|
||||
key={item.key}
|
||||
fieldName={item.name}
|
||||
form={form}
|
||||
absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]}
|
||||
absoluteItemPath={[
|
||||
...absoluteSettingsPath,
|
||||
groupKey,
|
||||
group.name,
|
||||
item.name,
|
||||
]}
|
||||
delayMode="number"
|
||||
onRemove={() => removeItem(item.name)}
|
||||
/>
|
||||
@@ -714,8 +808,18 @@ function HeaderCustomGroups({
|
||||
}
|
||||
|
||||
function UdpMasksList({
|
||||
base, form, isHysteria, isWireguard, network,
|
||||
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) {
|
||||
base,
|
||||
form,
|
||||
isHysteria,
|
||||
isWireguard,
|
||||
network,
|
||||
}: {
|
||||
base: (string | number)[];
|
||||
form: FormInstance;
|
||||
isHysteria: boolean;
|
||||
isWireguard: boolean;
|
||||
network: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.List name={[...base, 'udp']}>
|
||||
@@ -753,7 +857,14 @@ function UdpMasksList({
|
||||
}
|
||||
|
||||
function UdpMaskItem({
|
||||
fieldName, displayIndex, form, listPath, isHysteria, isWireguard, network, onRemove,
|
||||
fieldName,
|
||||
displayIndex,
|
||||
form,
|
||||
listPath,
|
||||
isHysteria,
|
||||
isWireguard,
|
||||
network,
|
||||
onRemove,
|
||||
}: {
|
||||
fieldName: number;
|
||||
displayIndex: number;
|
||||
@@ -778,16 +889,16 @@ function UdpMaskItem({
|
||||
const options = isHysteria
|
||||
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
|
||||
: [
|
||||
// Salamander is the mask xray-core's own wireguard finalmask example
|
||||
// uses; it stays hysteria-only elsewhere to keep legacy parity.
|
||||
...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []),
|
||||
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'realm', label: 'Realm' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
];
|
||||
// Salamander is the mask xray-core's own wireguard finalmask example
|
||||
// uses; it stays hysteria-only elsewhere to keep legacy parity.
|
||||
...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []),
|
||||
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'realm', label: 'Realm' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -809,12 +920,20 @@ function UdpMaskItem({
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])}
|
||||
shouldUpdate={(prev, curr) =>
|
||||
getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
|
||||
if (type === 'salamander') {
|
||||
return <SalamanderUdpMaskSettings fieldName={fieldName} form={form} absolutePath={absolutePath} />;
|
||||
return (
|
||||
<SalamanderUdpMaskSettings
|
||||
fieldName={fieldName}
|
||||
form={form}
|
||||
absolutePath={absolutePath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === 'mkcp-legacy') {
|
||||
return (
|
||||
@@ -848,7 +967,11 @@ function UdpMaskItem({
|
||||
if (type === 'xicmp') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Dgram" name={[fieldName, 'settings', 'dgram']} valuePropName="checked">
|
||||
<Form.Item
|
||||
label="Dgram"
|
||||
name={[fieldName, 'settings', 'dgram']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="IPs" name={[fieldName, 'settings', 'ips']}>
|
||||
@@ -864,10 +987,20 @@ function UdpMaskItem({
|
||||
<Input placeholder="realm://token@host:port/id" />
|
||||
</Form.Item>
|
||||
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="host:port"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Divider plain style={{ margin: '8px 0' }}>TLS (optional)</Divider>
|
||||
<Form.Item label="Server Name" name={[fieldName, 'settings', 'tlsConfig', 'serverName']}>
|
||||
<Divider plain style={{ margin: '8px 0' }}>
|
||||
TLS (optional)
|
||||
</Divider>
|
||||
<Form.Item
|
||||
label="Server Name"
|
||||
name={[fieldName, 'settings', 'tlsConfig', 'serverName']}
|
||||
>
|
||||
<Input placeholder="SNI for the realm server (leave empty to skip TLS)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
|
||||
@@ -881,12 +1014,11 @@ function UdpMaskItem({
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Fingerprint" name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={UTLS_FINGERPRINT_OPTIONS}
|
||||
/>
|
||||
<Form.Item
|
||||
label="Fingerprint"
|
||||
name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}
|
||||
>
|
||||
<Select allowClear style={{ width: '100%' }} options={UTLS_FINGERPRINT_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Allow Insecure"
|
||||
@@ -924,7 +1056,9 @@ function UdpMaskItem({
|
||||
}
|
||||
|
||||
function SalamanderUdpMaskSettings({
|
||||
fieldName, form, absolutePath,
|
||||
fieldName,
|
||||
form,
|
||||
absolutePath,
|
||||
}: {
|
||||
fieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -939,9 +1073,11 @@ function SalamanderUdpMaskSettings({
|
||||
<>
|
||||
<Form.Item
|
||||
label="Mode"
|
||||
extra={mode === 'gecko'
|
||||
? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
|
||||
: 'Scrambles each packet into random-looking bytes.'}
|
||||
extra={
|
||||
mode === 'gecko'
|
||||
? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
|
||||
: 'Scrambles each packet into random-looking bytes.'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={mode}
|
||||
@@ -952,7 +1088,10 @@ function SalamanderUdpMaskSettings({
|
||||
packetSizePath,
|
||||
parseGeckoPacketSize(current)
|
||||
? current
|
||||
: formatGeckoPacketSize(DEFAULT_GECKO_PACKET_SIZE.min, DEFAULT_GECKO_PACKET_SIZE.max),
|
||||
: formatGeckoPacketSize(
|
||||
DEFAULT_GECKO_PACKET_SIZE.min,
|
||||
DEFAULT_GECKO_PACKET_SIZE.max,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
form.setFieldValue(packetSizePath, undefined);
|
||||
@@ -973,10 +1112,12 @@ function SalamanderUdpMaskSettings({
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)}
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
@@ -1031,7 +1172,9 @@ function GeckoPacketSizeInput({
|
||||
}
|
||||
|
||||
function UdpHeaderCustom({
|
||||
udpFieldName, form, absoluteSettingsPath,
|
||||
udpFieldName,
|
||||
form,
|
||||
absoluteSettingsPath,
|
||||
}: {
|
||||
udpFieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -1083,7 +1226,9 @@ function UdpHeaderCustom({
|
||||
}
|
||||
|
||||
function NoiseItems({
|
||||
udpFieldName, form, absoluteSettingsPath,
|
||||
udpFieldName,
|
||||
form,
|
||||
absoluteSettingsPath,
|
||||
}: {
|
||||
udpFieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -1137,7 +1282,11 @@ function NoiseItems({
|
||||
}
|
||||
|
||||
function ItemEditor({
|
||||
fieldName, form, absoluteItemPath, delayMode, onRemove: _onRemove,
|
||||
fieldName,
|
||||
form,
|
||||
absoluteItemPath,
|
||||
delayMode,
|
||||
onRemove: _onRemove,
|
||||
}: {
|
||||
fieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -1190,7 +1339,10 @@ function ItemEditor({
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => getDeep(prev, [...absoluteItemPath, 'type']) !== getDeep(curr, [...absoluteItemPath, 'type'])}
|
||||
shouldUpdate={(prev, curr) =>
|
||||
getDeep(prev, [...absoluteItemPath, 'type']) !==
|
||||
getDeep(curr, [...absoluteItemPath, 'type'])
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
|
||||
@@ -1228,7 +1380,9 @@ function ItemEditor({
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
|
||||
onClick={() =>
|
||||
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
@@ -1247,7 +1401,9 @@ function ItemEditor({
|
||||
|
||||
function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
|
||||
const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
|
||||
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as Record<string, unknown> | undefined;
|
||||
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hasUdpHop = udpHop != null;
|
||||
|
||||
return (
|
||||
@@ -1315,7 +1471,11 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
|
||||
<Form.Item label="Keep Alive Period (s)" name={[...base, 'keepAlivePeriod']}>
|
||||
<InputNumber min={2} max={60} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Disable Path MTU Dis" name={[...base, 'disablePathMTUDiscovery']} valuePropName="checked">
|
||||
<Form.Item
|
||||
label="Disable Path MTU Dis"
|
||||
name={[...base, 'disablePathMTUDiscovery']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ export function selectionFromValue(value: string, known: ReadonlySet<string>): s
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function mergeSelection(value: string, selected: string[], known: ReadonlySet<string>): string {
|
||||
export function mergeSelection(
|
||||
value: string,
|
||||
selected: string[],
|
||||
known: ReadonlySet<string>,
|
||||
): string {
|
||||
const canonicalKnown = new Set([...known].map(canonicalToken));
|
||||
const kept = new Set(
|
||||
selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
|
||||
|
||||
@@ -20,12 +20,14 @@ export function buildClonePayload(dbInbound: DBInbound, port: number, nodeId: nu
|
||||
const fallback = createDefaultInboundSettings(dbInbound.protocol);
|
||||
clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
|
||||
}
|
||||
const streamSettingsString = typeof dbInbound.streamSettings === 'string'
|
||||
? dbInbound.streamSettings
|
||||
: JSON.stringify(dbInbound.streamSettings ?? {});
|
||||
const sniffingString = typeof dbInbound.sniffing === 'string'
|
||||
? dbInbound.sniffing
|
||||
: JSON.stringify(dbInbound.sniffing ?? {});
|
||||
const streamSettingsString =
|
||||
typeof dbInbound.streamSettings === 'string'
|
||||
? dbInbound.streamSettings
|
||||
: JSON.stringify(dbInbound.streamSettings ?? {});
|
||||
const sniffingString =
|
||||
typeof dbInbound.sniffing === 'string'
|
||||
? dbInbound.sniffing
|
||||
: JSON.stringify(dbInbound.sniffing ?? {});
|
||||
return {
|
||||
up: 0,
|
||||
down: 0,
|
||||
@@ -59,7 +61,9 @@ export function pickClonePort(used: Set<number> | undefined): number {
|
||||
port = RandomUtil.randomInteger(10000, 60000);
|
||||
}
|
||||
if (used.has(port)) {
|
||||
for (port = 10000; port <= 60000 && used.has(port); port++) { /* dense-range scan */ }
|
||||
for (port = 10000; port <= 60000 && used.has(port); port++) {
|
||||
/* dense-range scan */
|
||||
}
|
||||
if (port > 60000) port = RandomUtil.randomInteger(10000, 60000);
|
||||
}
|
||||
return port;
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
|
||||
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
|
||||
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
|
||||
import type { MtprotoClient, MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
|
||||
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
|
||||
import type {
|
||||
ShadowsocksClient,
|
||||
ShadowsocksInboundSettings,
|
||||
} from '@/schemas/protocols/inbound/shadowsocks';
|
||||
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
|
||||
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
|
||||
import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
|
||||
@@ -107,9 +110,13 @@ export interface ShadowsocksClientSeed extends ClientBaseSeed {
|
||||
// (the parent inbound's method is authoritative); only 2022-blake3 multi-
|
||||
// user inbounds use the per-client method. Callers pass `ssMethod` to seed
|
||||
// a method-specific password length when creating a multi-user client.
|
||||
export function createDefaultShadowsocksClient(seed: ShadowsocksClientSeed = {}): ShadowsocksClient {
|
||||
export function createDefaultShadowsocksClient(
|
||||
seed: ShadowsocksClientSeed = {},
|
||||
): ShadowsocksClient {
|
||||
const method = seed.method ?? '';
|
||||
const password = seed.password ?? RandomUtil.randomShadowsocksPassword(seed.ssMethod ?? '2022-blake3-aes-256-gcm');
|
||||
const password =
|
||||
seed.password ??
|
||||
RandomUtil.randomShadowsocksPassword(seed.ssMethod ?? '2022-blake3-aes-256-gcm');
|
||||
return {
|
||||
method,
|
||||
password,
|
||||
@@ -294,17 +301,29 @@ export type AnyInboundSettings =
|
||||
|
||||
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
|
||||
switch (protocol) {
|
||||
case 'vless': return createDefaultVlessInboundSettings();
|
||||
case 'vmess': return createDefaultVmessInboundSettings();
|
||||
case 'trojan': return createDefaultTrojanInboundSettings();
|
||||
case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
|
||||
case 'hysteria': return createDefaultHysteriaInboundSettings();
|
||||
case 'http': return createDefaultHttpInboundSettings();
|
||||
case 'mixed': return createDefaultMixedInboundSettings();
|
||||
case 'tunnel': return createDefaultTunnelInboundSettings();
|
||||
case 'tun': return createDefaultTunInboundSettings();
|
||||
case 'wireguard': return createDefaultWireguardInboundSettings();
|
||||
case 'mtproto': return createDefaultMtprotoInboundSettings();
|
||||
default: return null;
|
||||
case 'vless':
|
||||
return createDefaultVlessInboundSettings();
|
||||
case 'vmess':
|
||||
return createDefaultVmessInboundSettings();
|
||||
case 'trojan':
|
||||
return createDefaultTrojanInboundSettings();
|
||||
case 'shadowsocks':
|
||||
return createDefaultShadowsocksInboundSettings();
|
||||
case 'hysteria':
|
||||
return createDefaultHysteriaInboundSettings();
|
||||
case 'http':
|
||||
return createDefaultHttpInboundSettings();
|
||||
case 'mixed':
|
||||
return createDefaultMixedInboundSettings();
|
||||
case 'tunnel':
|
||||
return createDefaultTunnelInboundSettings();
|
||||
case 'tun':
|
||||
return createDefaultTunInboundSettings();
|
||||
case 'wireguard':
|
||||
return createDefaultWireguardInboundSettings();
|
||||
case 'mtproto':
|
||||
return createDefaultMtprotoInboundSettings();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schemas/forms/inbound-form';
|
||||
import type {
|
||||
InboundFormValues,
|
||||
ShareAddrStrategy,
|
||||
TrafficReset,
|
||||
} from '@/schemas/forms/inbound-form';
|
||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||
import {
|
||||
HysteriaClientSchema,
|
||||
@@ -143,7 +147,7 @@ function healStreamNetworkKey(stream: Record<string, unknown>): void {
|
||||
|
||||
function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
|
||||
return Array.isArray(tls?.certificates) ? tls.certificates as Record<string, unknown>[] : [];
|
||||
return Array.isArray(tls?.certificates) ? (tls.certificates as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
|
||||
@@ -165,9 +169,8 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
|
||||
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
|
||||
const rawStream = coerceJsonObject(row.streamSettings);
|
||||
const streamSettings = Object.keys(rawStream).length > 0
|
||||
? (rawStream as StreamSettings)
|
||||
: undefined;
|
||||
const streamSettings =
|
||||
Object.keys(rawStream).length > 0 ? (rawStream as StreamSettings) : undefined;
|
||||
if (streamSettings) {
|
||||
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
||||
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
|
||||
@@ -251,14 +254,22 @@ export function pruneEmpty(value: unknown): unknown {
|
||||
// gives us the canonical projection.
|
||||
function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
||||
switch (protocol) {
|
||||
case 'vless': return VlessClientSchema;
|
||||
case 'vmess': return VmessClientSchema;
|
||||
case 'trojan': return TrojanClientSchema;
|
||||
case 'shadowsocks': return ShadowsocksClientSchema;
|
||||
case 'hysteria': return HysteriaClientSchema;
|
||||
case 'wireguard': return WireguardClientSchema;
|
||||
case 'mtproto': return MtprotoClientSchema;
|
||||
default: return null;
|
||||
case 'vless':
|
||||
return VlessClientSchema;
|
||||
case 'vmess':
|
||||
return VmessClientSchema;
|
||||
case 'trojan':
|
||||
return TrojanClientSchema;
|
||||
case 'shadowsocks':
|
||||
return ShadowsocksClientSchema;
|
||||
case 'hysteria':
|
||||
return HysteriaClientSchema;
|
||||
case 'wireguard':
|
||||
return WireguardClientSchema;
|
||||
case 'mtproto':
|
||||
return MtprotoClientSchema;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +316,9 @@ export function dropLegacyOptionalEmpties(
|
||||
// sub-fields are empty; otherwise drop only the empty sub-arrays so
|
||||
// the wire payload doesn't carry a stray `"tcp": []` next to a
|
||||
// populated UDP mask list (and vice versa).
|
||||
const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown } | undefined;
|
||||
const fm = stream.finalmask as
|
||||
| { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown }
|
||||
| undefined;
|
||||
if (fm && typeof fm === 'object') {
|
||||
const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
|
||||
const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
|
||||
@@ -359,7 +372,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
|
||||
// mtproto is mtg-served, not Xray, so sniffing never applies — emit empty
|
||||
// rather than the default { enabled: false } so the row carries no sniffing.
|
||||
sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '',
|
||||
sniffing: canEnableSniffing({ protocol: values.protocol })
|
||||
? JSON.stringify(normalizeSniffing(values.sniffing))
|
||||
: '',
|
||||
tag: values.tag,
|
||||
shareAddrStrategy: values.shareAddrStrategy,
|
||||
shareAddr: values.shareAddr,
|
||||
|
||||
@@ -22,7 +22,10 @@ export interface DbInboundLike {
|
||||
shareAddr?: string;
|
||||
}
|
||||
|
||||
function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
|
||||
function fillProtocolSettingsDefaults(
|
||||
protocol: string,
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const parsed = InboundSettingsSchema.safeParse({ protocol, settings });
|
||||
if (parsed.success) {
|
||||
const tagged = parsed.data as { settings: Record<string, unknown> };
|
||||
@@ -36,9 +39,10 @@ export function inboundFromDb(raw: DbInboundLike): Inbound {
|
||||
const settings = fillProtocolSettingsDefaults(raw.protocol, rawSettings);
|
||||
const streamSettingsRaw = coerceInboundJsonField(raw.streamSettings);
|
||||
const sniffing = coerceInboundJsonField(raw.sniffing);
|
||||
const streamSettings = Object.keys(streamSettingsRaw).length === 0
|
||||
? streamSettingsRaw
|
||||
: fillStreamDefaults(streamSettingsRaw);
|
||||
const streamSettings =
|
||||
Object.keys(streamSettingsRaw).length === 0
|
||||
? streamSettingsRaw
|
||||
: fillStreamDefaults(streamSettingsRaw);
|
||||
return {
|
||||
protocol: raw.protocol,
|
||||
port: raw.port,
|
||||
|
||||
@@ -23,7 +23,8 @@ import { deriveSpiderX } from './spider-x';
|
||||
// directly.
|
||||
|
||||
type ForceTls = 'same' | 'tls' | 'none';
|
||||
const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
const SHARE_HOSTNAME_RE =
|
||||
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
|
||||
// Format a host for interpolation into a URL authority. IPv6 literals are
|
||||
// wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is.
|
||||
@@ -56,7 +57,12 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
|
||||
}
|
||||
if (xhttp.xPaddingObfsMode === true) {
|
||||
extra.xPaddingObfsMode = true;
|
||||
for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) {
|
||||
for (const k of [
|
||||
'xPaddingKey',
|
||||
'xPaddingHeader',
|
||||
'xPaddingPlacement',
|
||||
'xPaddingMethod',
|
||||
] as const) {
|
||||
const v = xhttp[k];
|
||||
if (typeof v === 'string' && v.length > 0) extra[k] = v;
|
||||
}
|
||||
@@ -108,7 +114,10 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
|
||||
return Object.keys(extra).length > 0 ? extra : null;
|
||||
}
|
||||
|
||||
function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record<string, unknown>): void {
|
||||
function applyXhttpExtraToObj(
|
||||
xhttp: XHttpStreamSettings | undefined,
|
||||
obj: Record<string, unknown>,
|
||||
): void {
|
||||
if (!xhttp) return;
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
obj.x_padding_bytes = xhttp.xPaddingBytes;
|
||||
@@ -161,9 +170,11 @@ function applyExternalProxyTLSObj(
|
||||
security: string,
|
||||
): void {
|
||||
if (!externalProxy || security !== 'tls') return;
|
||||
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
const sni =
|
||||
externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
if (sni && sni.length > 0) obj.sni = sni;
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0)
|
||||
obj.fp = externalProxy.fingerprint;
|
||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) obj.alpn = alpn;
|
||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||
@@ -171,7 +182,8 @@ function applyExternalProxyTLSObj(
|
||||
if (externalProxy.verifyPeerCertByName && externalProxy.verifyPeerCertByName.length > 0) {
|
||||
obj.vcn = externalProxy.verifyPeerCertByName;
|
||||
}
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0)
|
||||
obj.ech = externalProxy.echConfigList;
|
||||
}
|
||||
|
||||
export interface GenVmessLinkInput {
|
||||
@@ -227,8 +239,8 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
||||
if (request) {
|
||||
obj.path = request.path.join(',');
|
||||
const host =
|
||||
getHeaderValue(header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
getHeaderValue(header.response?.headers, 'host') ||
|
||||
getHeaderValue(request.headers, 'host');
|
||||
if (host) obj.host = host;
|
||||
}
|
||||
}
|
||||
@@ -287,7 +299,10 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
||||
// directly. Number values get coerced via .toString() on set — same as
|
||||
// what URLSearchParams does internally so the resulting URL bytes match.
|
||||
|
||||
function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
|
||||
function applyXhttpExtraToParams(
|
||||
xhttp: XHttpStreamSettings | undefined,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
if (!xhttp) return;
|
||||
params.set('path', xhttp.path);
|
||||
const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
|
||||
@@ -300,7 +315,10 @@ function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params:
|
||||
if (extra) params.set('extra', JSON.stringify(extra));
|
||||
}
|
||||
|
||||
function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
|
||||
function applyFinalMaskToParams(
|
||||
finalmask: FinalMaskStreamSettings | undefined,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
const payload = serializeFinalMask(finalmask);
|
||||
if (payload.length > 0) params.set('fm', payload);
|
||||
}
|
||||
@@ -311,9 +329,11 @@ function applyExternalProxyTLSParams(
|
||||
security: string,
|
||||
): void {
|
||||
if (!externalProxy || security !== 'tls') return;
|
||||
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
const sni =
|
||||
externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0)
|
||||
params.set('fp', externalProxy.fingerprint);
|
||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) params.set('alpn', alpn);
|
||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||
@@ -321,7 +341,8 @@ function applyExternalProxyTLSParams(
|
||||
if (externalProxy.verifyPeerCertByName && externalProxy.verifyPeerCertByName.length > 0) {
|
||||
params.set('vcn', externalProxy.verifyPeerCertByName);
|
||||
}
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0)
|
||||
params.set('ech', externalProxy.echConfigList);
|
||||
}
|
||||
|
||||
export interface GenVlessLinkInput {
|
||||
@@ -344,7 +365,8 @@ export function applyVlessRoute(id: string, route: string | undefined): string {
|
||||
if (r === '' || !/^\d{1,5}$/.test(r)) return id;
|
||||
const n = Number(r);
|
||||
if (n > 65535) return id;
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) return id;
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id))
|
||||
return id;
|
||||
return id.slice(0, 14) + n.toString(16).padStart(4, '0') + id.slice(18);
|
||||
}
|
||||
|
||||
@@ -381,8 +403,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
if (request) {
|
||||
params.set('path', request.path.join(','));
|
||||
const host =
|
||||
getHeaderValue(tcp.header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
getHeaderValue(tcp.header.response?.headers, 'host') ||
|
||||
getHeaderValue(request.headers, 'host');
|
||||
if (host) params.set('host', host);
|
||||
params.set('headerType', 'http');
|
||||
}
|
||||
@@ -434,16 +456,15 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
params.set('fp', reality.settings.fingerprint);
|
||||
|
||||
const sni =
|
||||
reality.settings.serverName ||
|
||||
reality.serverNames?.[0] ||
|
||||
reality.target?.split(':')[0];
|
||||
reality.settings.serverName || reality.serverNames?.[0] || reality.target?.split(':')[0];
|
||||
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
|
||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
||||
if (spx.length > 0) params.set('spx', spx);
|
||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||
if (reality.settings.mldsa65Verify.length > 0)
|
||||
params.set('pqv', reality.settings.mldsa65Verify);
|
||||
}
|
||||
} else {
|
||||
params.set('security', 'none');
|
||||
@@ -453,15 +474,20 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
|
||||
// vlessFlowAllowed and the form's flow-field gating so panel link, share
|
||||
// link and subscription agree.
|
||||
if (flow.length > 0 && canEnableTlsFlow({
|
||||
protocol: inbound.protocol,
|
||||
settings: inbound.settings,
|
||||
streamSettings: stream,
|
||||
})) {
|
||||
if (
|
||||
flow.length > 0 &&
|
||||
canEnableTlsFlow({
|
||||
protocol: inbound.protocol,
|
||||
settings: inbound.settings,
|
||||
streamSettings: stream,
|
||||
})
|
||||
) {
|
||||
params.set('flow', flow);
|
||||
}
|
||||
|
||||
const url = new URL(`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`);
|
||||
const url = new URL(
|
||||
`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`,
|
||||
);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
@@ -471,7 +497,10 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
// VLESS and VMess don't call this because they have minor per-protocol
|
||||
// quirks inline (vmess maps `multi` differently into obj.type; vless sets
|
||||
// encryption=none up-front).
|
||||
function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
||||
function writeNetworkParams(
|
||||
stream: NonNullable<Inbound['streamSettings']>,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
if (stream.network === 'tcp') {
|
||||
const tcp = stream.tcpSettings;
|
||||
if (tcp.header?.type === 'http') {
|
||||
@@ -479,8 +508,8 @@ function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, para
|
||||
if (request) {
|
||||
params.set('path', request.path.join(','));
|
||||
const host =
|
||||
getHeaderValue(tcp.header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
getHeaderValue(tcp.header.response?.headers, 'host') ||
|
||||
getHeaderValue(request.headers, 'host');
|
||||
if (host) params.set('host', host);
|
||||
params.set('headerType', 'http');
|
||||
}
|
||||
@@ -507,7 +536,10 @@ function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, para
|
||||
}
|
||||
}
|
||||
|
||||
function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
||||
function writeTlsParams(
|
||||
stream: NonNullable<Inbound['streamSettings']>,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
if (stream.security !== 'tls') return;
|
||||
const tls = stream.tlsSettings;
|
||||
params.set('fp', tls.settings.fingerprint);
|
||||
@@ -524,16 +556,18 @@ function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params:
|
||||
|
||||
// Reality query-string writer shared by VLESS and Trojan. Preserves the
|
||||
// legacy SNI-omission quirk (see genVlessLink for the full story).
|
||||
function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams, clientKey: string): void {
|
||||
function writeRealityParams(
|
||||
stream: NonNullable<Inbound['streamSettings']>,
|
||||
params: URLSearchParams,
|
||||
clientKey: string,
|
||||
): void {
|
||||
if (stream.security !== 'reality') return;
|
||||
const reality = stream.realitySettings;
|
||||
params.set('pbk', reality.settings.publicKey);
|
||||
params.set('fp', reality.settings.fingerprint);
|
||||
|
||||
const sni =
|
||||
reality.settings.serverName ||
|
||||
reality.serverNames?.[0] ||
|
||||
reality.target?.split(':')[0];
|
||||
reality.settings.serverName || reality.serverNames?.[0] || reality.target?.split(':')[0];
|
||||
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
|
||||
@@ -591,7 +625,9 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
||||
params.set('security', 'none');
|
||||
}
|
||||
|
||||
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`);
|
||||
const url = new URL(
|
||||
`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`,
|
||||
);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
@@ -826,9 +862,8 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
|
||||
const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
|
||||
url.username = peer.privateKey ?? '';
|
||||
|
||||
const pubKey = settings.secretKey.length > 0
|
||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||
: '';
|
||||
const pubKey =
|
||||
settings.secretKey.length > 0 ? Wireguard.generateKeypair(settings.secretKey).publicKey : '';
|
||||
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
|
||||
if (peer.allowedIPs.length > 0) {
|
||||
url.searchParams.set('address', peer.allowedIPs.join(','));
|
||||
@@ -852,9 +887,8 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
const peer = settings.peers[peerIndex];
|
||||
if (!peer) return '';
|
||||
|
||||
const pubKey = settings.secretKey.length > 0
|
||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||
: '';
|
||||
const pubKey =
|
||||
settings.secretKey.length > 0 ? Wireguard.generateKeypair(settings.secretKey).publicKey : '';
|
||||
|
||||
let txt = `[Interface]\n`;
|
||||
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
||||
@@ -945,12 +979,7 @@ function isUnixSocketListen(listen: string): boolean {
|
||||
|
||||
function normalizeShareHost(host: string): string {
|
||||
const h = host.trim();
|
||||
if (
|
||||
h.length === 0
|
||||
|| h.includes('://')
|
||||
|| h.startsWith('//')
|
||||
|| /[/?#@]/.test(h)
|
||||
) {
|
||||
if (h.length === 0 || h.includes('://') || h.startsWith('//') || /[/?#@]/.test(h)) {
|
||||
return '';
|
||||
}
|
||||
if (h.startsWith('[')) {
|
||||
@@ -972,7 +1001,9 @@ function normalizeShareHost(host: string): string {
|
||||
}
|
||||
|
||||
function isShareableHost(host: string): boolean {
|
||||
const h = normalizeShareHost(host).replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const h = normalizeShareHost(host)
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.toLowerCase();
|
||||
if (h.length === 0) return false;
|
||||
if (h === '0.0.0.0' || h === '::' || h === '::0') return false;
|
||||
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return false;
|
||||
@@ -1031,14 +1062,21 @@ export function resolveShareHost(
|
||||
// `node` strategy keeps the previous node-address-first behavior for
|
||||
// node-managed inbounds; other strategies let a row prefer its listen address
|
||||
// or a custom endpoint.
|
||||
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
|
||||
export function resolveAddr(
|
||||
inbound: Inbound,
|
||||
hostOverride: string,
|
||||
fallbackHostname: string,
|
||||
): string {
|
||||
return resolveShareHost(inbound, hostOverride, fallbackHostname);
|
||||
}
|
||||
|
||||
// A loopback browser host means the panel was reached through a tunnel (e.g.
|
||||
// SSH-forwarded 127.0.0.1/localhost), so it can never be a shareable link host.
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
const h = host.trim().replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const h = host
|
||||
.trim()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.toLowerCase();
|
||||
return h === 'localhost' || h === '::1' || h.startsWith('127.');
|
||||
}
|
||||
|
||||
@@ -1056,7 +1094,16 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
|
||||
// `this.clients` getter, which used isSSMultiUser to gate). Returns null
|
||||
// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
|
||||
// clients, and any protocol without a clients array.
|
||||
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; secret?: string; email?: string; subId?: string };
|
||||
type ClientShape = {
|
||||
id?: string;
|
||||
security?: VmessSecurity;
|
||||
flow?: VlessClient['flow'];
|
||||
password?: string;
|
||||
auth?: string;
|
||||
secret?: string;
|
||||
email?: string;
|
||||
subId?: string;
|
||||
};
|
||||
|
||||
// Mirror of the Go subKey: the stable per-client identity spx derivation
|
||||
// keys on — subscription id first, unique email as the fallback.
|
||||
@@ -1100,18 +1147,34 @@ export interface GenLinkInput {
|
||||
// goes through genWireguardLinks/Configs separately, http/mixed/tunnel
|
||||
// don't have share URLs).
|
||||
export function genLink(input: GenLinkInput): string {
|
||||
const { inbound, address, port = inbound.port, forceTls = 'same', remark = '', client, externalProxy = null } = input;
|
||||
const {
|
||||
inbound,
|
||||
address,
|
||||
port = inbound.port,
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
client,
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
switch (inbound.protocol) {
|
||||
case 'vmess':
|
||||
return genVmessLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
forceTls,
|
||||
remark,
|
||||
clientId: client.id ?? '',
|
||||
security: client.security,
|
||||
externalProxy,
|
||||
});
|
||||
case 'vless':
|
||||
return genVlessLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
forceTls,
|
||||
remark,
|
||||
clientId: client.id ?? '',
|
||||
clientKey: clientSubKey(client),
|
||||
flow: client.flow,
|
||||
@@ -1120,21 +1183,32 @@ export function genLink(input: GenLinkInput): string {
|
||||
case 'shadowsocks': {
|
||||
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
|
||||
return genShadowsocksLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
forceTls,
|
||||
remark,
|
||||
clientPassword: isMultiUser ? (client.password ?? '') : '',
|
||||
externalProxy,
|
||||
});
|
||||
}
|
||||
case 'trojan':
|
||||
return genTrojanLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
forceTls,
|
||||
remark,
|
||||
clientPassword: client.password ?? '',
|
||||
clientKey: clientSubKey(client),
|
||||
externalProxy,
|
||||
});
|
||||
case 'hysteria':
|
||||
return genHysteriaLink({
|
||||
inbound, address, port, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
remark,
|
||||
clientAuth: client.auth ?? '',
|
||||
externalProxy,
|
||||
});
|
||||
@@ -1163,13 +1237,7 @@ export interface GenAllLinksInput {
|
||||
// remark plus the externalProxy remark, dash-joined (the configurable
|
||||
// subscription remark model was removed; subscription output uses the template).
|
||||
export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
||||
const {
|
||||
inbound,
|
||||
remark = '',
|
||||
client,
|
||||
hostOverride = '',
|
||||
fallbackHostname,
|
||||
} = input;
|
||||
const { inbound, remark = '', client, hostOverride = '', fallbackHostname } = input;
|
||||
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const port = inbound.port;
|
||||
@@ -1180,7 +1248,12 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
||||
const externals = inbound.streamSettings?.externalProxy;
|
||||
if (!externals || externals.length === 0) {
|
||||
const r = composeRemark('');
|
||||
return [{ remark: r, link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }) }];
|
||||
return [
|
||||
{
|
||||
remark: r,
|
||||
link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }),
|
||||
},
|
||||
];
|
||||
}
|
||||
return externals.map((ep) => {
|
||||
const r = composeRemark(ep.remark);
|
||||
@@ -1212,12 +1285,7 @@ export interface GenInboundLinksInput {
|
||||
// and emits per-peer .conf blocks for wireguard. Returns '' for the
|
||||
// other clientless protocols (http, mixed, tunnel).
|
||||
export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
const {
|
||||
inbound,
|
||||
remark = '',
|
||||
hostOverride = '',
|
||||
fallbackHostname,
|
||||
} = input;
|
||||
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const clients = getInboundClients(inbound);
|
||||
if (clients) {
|
||||
@@ -1229,7 +1297,13 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
return links.join('\r\n');
|
||||
}
|
||||
if (inbound.protocol === 'shadowsocks') {
|
||||
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
|
||||
return genShadowsocksLink({
|
||||
inbound,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
forceTls: 'same',
|
||||
remark,
|
||||
});
|
||||
}
|
||||
if (inbound.protocol === 'wireguard') {
|
||||
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
||||
@@ -1269,13 +1343,15 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
||||
const peers = wgRenderPeers(baseSettings);
|
||||
const settings: WireguardInboundSettings = { ...baseSettings, peers };
|
||||
return peers
|
||||
.map((p, i) => genWireguardLink({
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
peerIndex: i,
|
||||
}))
|
||||
.map((p, i) =>
|
||||
genWireguardLink({
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
peerIndex: i,
|
||||
}),
|
||||
)
|
||||
.join('\r\n');
|
||||
}
|
||||
|
||||
@@ -1288,13 +1364,15 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
||||
const peers = wgRenderPeers(baseSettings);
|
||||
const settings: WireguardInboundSettings = { ...baseSettings, peers };
|
||||
return peers
|
||||
.map((p, i) => genWireguardConfig({
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
peerIndex: i,
|
||||
}))
|
||||
.map((p, i) =>
|
||||
genWireguardConfig({
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
peerIndex: i,
|
||||
}),
|
||||
)
|
||||
.join('\r\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -68,10 +68,7 @@ export interface InboundTagInput {
|
||||
export function composeInboundTag(input: InboundTagInput): string {
|
||||
const bits = inboundTransports(input.protocol, input.streamSettings, input.settings);
|
||||
return (
|
||||
nodeTagPrefix(input.nodeId)
|
||||
+ baseInboundTag(input.port ?? 0)
|
||||
+ '-'
|
||||
+ transportTagSuffix(bits)
|
||||
nodeTagPrefix(input.nodeId) + baseInboundTag(input.port ?? 0) + '-' + transportTagSuffix(bits)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ export function createHysteriaTlsSettingsWithDefaultCert(): Record<string, unkno
|
||||
const tls = createTlsSettingsWithDefaultCert();
|
||||
tls.alpn = ['h3'];
|
||||
|
||||
const settings = tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
|
||||
? { ...(tls.settings as Record<string, unknown>) }
|
||||
: {};
|
||||
const settings =
|
||||
tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
|
||||
? { ...(tls.settings as Record<string, unknown>) }
|
||||
: {};
|
||||
settings.fingerprint = '';
|
||||
tls.settings = settings;
|
||||
|
||||
|
||||
@@ -80,7 +80,9 @@ export function parseLinkParts(link: string): LinkParts | null {
|
||||
security = json.tls ?? '';
|
||||
remark = typeof json.ps === 'string' ? json.ps : '';
|
||||
port = json.port != null ? String(json.port) : '';
|
||||
} catch { /* unparseable payload, fall back to protocol only */ }
|
||||
} catch {
|
||||
/* unparseable payload, fall back to protocol only */
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
@@ -90,8 +92,14 @@ export function parseLinkParts(link: string): LinkParts | null {
|
||||
the URL authority, so fall back to it when there is no authority port. */
|
||||
port = url.port || (url.searchParams.get('port') ?? '');
|
||||
const hash = url.hash.replace(/^#/, '');
|
||||
try { remark = decodeURIComponent(hash); } catch { remark = hash; }
|
||||
} catch { /* not URL-shaped, fall back to protocol only */ }
|
||||
try {
|
||||
remark = decodeURIComponent(hash);
|
||||
} catch {
|
||||
remark = hash;
|
||||
}
|
||||
} catch {
|
||||
/* not URL-shaped, fall back to protocol only */
|
||||
}
|
||||
if (scheme === 'tg') security = 'FakeTLS';
|
||||
}
|
||||
if (security === 'none') security = '';
|
||||
@@ -113,10 +121,18 @@ export function linkMetaText(parts: LinkParts): string {
|
||||
export function LinkTags({ parts }: { parts: LinkParts }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Tag color={PROTOCOL_COLORS[parts.protocol]} style={TAG_STYLE}>{parts.protocol}</Tag>
|
||||
{parts.network && <Tag color={TRANSPORT_COLOR} style={TAG_STYLE}>{parts.network}</Tag>}
|
||||
<Tag color={PROTOCOL_COLORS[parts.protocol]} style={TAG_STYLE}>
|
||||
{parts.protocol}
|
||||
</Tag>
|
||||
{parts.network && (
|
||||
<Tag color={TRANSPORT_COLOR} style={TAG_STYLE}>
|
||||
{parts.network}
|
||||
</Tag>
|
||||
)}
|
||||
{parts.security && (
|
||||
<Tag color={SECURITY_COLORS[parts.security]} style={TAG_STYLE}>{parts.security}</Tag>
|
||||
<Tag color={SECURITY_COLORS[parts.security]} style={TAG_STYLE}>
|
||||
{parts.security}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -47,11 +47,13 @@ export function createDefaultDNSOutboundSettings(): DNSOutboundSettings {
|
||||
|
||||
export function createDefaultVmessOutboundSettings(): VmessOutboundSettings {
|
||||
return {
|
||||
vnext: [{
|
||||
address: '',
|
||||
port: 443,
|
||||
users: [{ id: '', security: 'auto' }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: '',
|
||||
port: 443,
|
||||
users: [{ id: '', security: 'auto' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,12 +80,14 @@ export function createDefaultTrojanOutboundSettings(): TrojanOutboundSettings {
|
||||
// initial state instead of an empty Select.
|
||||
export function createDefaultShadowsocksOutboundSettings(): ShadowsocksOutboundSettings {
|
||||
return {
|
||||
servers: [{
|
||||
address: '',
|
||||
port: 443,
|
||||
password: '',
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
}],
|
||||
servers: [
|
||||
{
|
||||
address: '',
|
||||
port: 443,
|
||||
password: '',
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,11 +115,13 @@ export function createDefaultWireguardOutboundSettings(
|
||||
mtu: 1420,
|
||||
secretKey,
|
||||
address: [],
|
||||
peers: [{
|
||||
publicKey: '',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: '',
|
||||
}],
|
||||
peers: [
|
||||
{
|
||||
publicKey: '',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: '',
|
||||
},
|
||||
],
|
||||
noKernelTun: false,
|
||||
};
|
||||
}
|
||||
@@ -149,18 +155,31 @@ export type AnyOutboundSettings =
|
||||
export function createDefaultOutboundSettings(protocol: string): AnyOutboundSettings | null {
|
||||
void RandomUtil;
|
||||
switch (protocol) {
|
||||
case 'freedom': return createDefaultFreedomOutboundSettings();
|
||||
case 'blackhole': return createDefaultBlackholeOutboundSettings();
|
||||
case 'dns': return createDefaultDNSOutboundSettings();
|
||||
case 'vmess': return createDefaultVmessOutboundSettings();
|
||||
case 'vless': return createDefaultVlessOutboundSettings();
|
||||
case 'trojan': return createDefaultTrojanOutboundSettings();
|
||||
case 'shadowsocks': return createDefaultShadowsocksOutboundSettings();
|
||||
case 'socks': return createDefaultSocksOutboundSettings();
|
||||
case 'http': return createDefaultHttpOutboundSettings();
|
||||
case 'wireguard': return createDefaultWireguardOutboundSettings();
|
||||
case 'hysteria': return createDefaultHysteriaOutboundSettings();
|
||||
case 'loopback': return createDefaultLoopbackOutboundSettings();
|
||||
default: return null;
|
||||
case 'freedom':
|
||||
return createDefaultFreedomOutboundSettings();
|
||||
case 'blackhole':
|
||||
return createDefaultBlackholeOutboundSettings();
|
||||
case 'dns':
|
||||
return createDefaultDNSOutboundSettings();
|
||||
case 'vmess':
|
||||
return createDefaultVmessOutboundSettings();
|
||||
case 'vless':
|
||||
return createDefaultVlessOutboundSettings();
|
||||
case 'trojan':
|
||||
return createDefaultTrojanOutboundSettings();
|
||||
case 'shadowsocks':
|
||||
return createDefaultShadowsocksOutboundSettings();
|
||||
case 'socks':
|
||||
return createDefaultSocksOutboundSettings();
|
||||
case 'http':
|
||||
return createDefaultHttpOutboundSettings();
|
||||
case 'wireguard':
|
||||
return createDefaultWireguardOutboundSettings();
|
||||
case 'hysteria':
|
||||
return createDefaultHysteriaOutboundSettings();
|
||||
case 'loopback':
|
||||
return createDefaultLoopbackOutboundSettings();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@ function asPort(value: unknown, fallback: number): number {
|
||||
function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
|
||||
const s = asString(value);
|
||||
if (!s) return '';
|
||||
return OutboundDomainStrategySchema.options.find(
|
||||
(v) => v.toLowerCase() === s.toLowerCase(),
|
||||
) ?? '';
|
||||
return (
|
||||
OutboundDomainStrategySchema.options.find((v) => v.toLowerCase() === s.toLowerCase()) ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
|
||||
@@ -131,14 +131,12 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
|
||||
}
|
||||
const reverse = asObject(raw.reverse);
|
||||
const reverseTag = asString(reverse.tag);
|
||||
const reverseSniffing = reverseTag
|
||||
? sniffingFromWire(reverse.sniffing)
|
||||
: SNIFFING_DEFAULT;
|
||||
const reverseSniffing = reverseTag ? sniffingFromWire(reverse.sniffing) : SNIFFING_DEFAULT;
|
||||
const savedSeed = asArray(raw.testseed);
|
||||
const testseed = savedSeed.length === 4
|
||||
&& savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
|
||||
? (savedSeed as number[])
|
||||
: [900, 500, 900, 256];
|
||||
const testseed =
|
||||
savedSeed.length === 4 && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
|
||||
? (savedSeed as number[])
|
||||
: [900, 500, 900, 256];
|
||||
return {
|
||||
address,
|
||||
port,
|
||||
@@ -167,7 +165,10 @@ function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
|
||||
address: asString(s.address),
|
||||
port: asPort(s.port, 443),
|
||||
password: asString(s.password),
|
||||
method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
|
||||
method: asString(
|
||||
s.method,
|
||||
'2022-blake3-aes-128-gcm',
|
||||
) as ShadowsocksOutboundFormSettings['method'],
|
||||
uot: asBool(s.uot),
|
||||
UoTVersion: asNumber(s.UoTVersion, 1),
|
||||
};
|
||||
@@ -213,9 +214,7 @@ function httpFromWire(raw: Raw): HttpOutboundFormSettings {
|
||||
|
||||
function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
|
||||
const secretKey = asString(raw.secretKey);
|
||||
const pubKey = secretKey.length > 0
|
||||
? Wireguard.generateKeypair(secretKey).publicKey
|
||||
: '';
|
||||
const pubKey = secretKey.length > 0 ? Wireguard.generateKeypair(secretKey).publicKey : '';
|
||||
const addressArr = asArray(raw.address).map((x) =>
|
||||
typeof x === 'number' ? String(x) : asString(x),
|
||||
);
|
||||
@@ -262,10 +261,13 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
const noises = asArray(raw.noises).map((n) => {
|
||||
const nn = asObject(n);
|
||||
return {
|
||||
type: (asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type']),
|
||||
type: asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type'],
|
||||
packet: asString(nn.packet, '10-20'),
|
||||
delay: asString(nn.delay, '10-16'),
|
||||
applyTo: (asString(nn.applyTo, 'ip') as FreedomOutboundFormSettings['noises'][number]['applyTo']),
|
||||
applyTo: asString(
|
||||
nn.applyTo,
|
||||
'ip',
|
||||
) as FreedomOutboundFormSettings['noises'][number]['applyTo'],
|
||||
};
|
||||
});
|
||||
const finalRulesRaw = asArray(raw.finalRules);
|
||||
@@ -275,7 +277,9 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
? rr.network.map((x) => asString(x)).join(',')
|
||||
: asString(rr.network);
|
||||
return {
|
||||
action: (asString(rr.action, 'block') === 'allow' ? 'allow' : 'block') as FreedomFinalRuleForm['action'],
|
||||
action: (asString(rr.action, 'block') === 'allow'
|
||||
? 'allow'
|
||||
: 'block') as FreedomFinalRuleForm['action'],
|
||||
network,
|
||||
port: asString(rr.port),
|
||||
ip: asArray(rr.ip).map((x) => asString(x)),
|
||||
@@ -293,9 +297,8 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
// legacy behavior: when the wire omits fragment, leave all four fields
|
||||
// empty so the modal's "Fragment" Switch starts off. When present,
|
||||
// surface whatever the wire holds verbatim.
|
||||
const wireHasFragment = raw.fragment != null
|
||||
&& typeof raw.fragment === 'object'
|
||||
&& Object.keys(fragment).length > 0;
|
||||
const wireHasFragment =
|
||||
raw.fragment != null && typeof raw.fragment === 'object' && Object.keys(fragment).length > 0;
|
||||
return {
|
||||
domainStrategy: targetStrategyFromWire(
|
||||
asString(raw.targetStrategy) || asString(raw.domainStrategy),
|
||||
@@ -304,7 +307,7 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
userLevel: asNumber(raw.userLevel, 0),
|
||||
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
|
||||
const n = asNumber(raw.proxyProtocol, 0);
|
||||
return (n === 1 || n === 2) ? n : 0;
|
||||
return n === 1 || n === 2 ? n : 0;
|
||||
})(),
|
||||
fragment: wireHasFragment
|
||||
? {
|
||||
@@ -337,10 +340,13 @@ function dnsRuleFromWire(raw: unknown): DnsRuleForm {
|
||||
? r.domain.map((x) => asString(x)).join(',')
|
||||
: asString(r.domain);
|
||||
const action = asString(r.action, 'direct');
|
||||
const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action)
|
||||
? action
|
||||
: 'direct';
|
||||
return { action: validAction as DnsRuleForm['action'], qType, domain, rCode: asNumber(r.rCode, 0) };
|
||||
const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action) ? action : 'direct';
|
||||
return {
|
||||
action: validAction as DnsRuleForm['action'],
|
||||
qType,
|
||||
domain,
|
||||
rCode: asNumber(r.rCode, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
|
||||
@@ -348,7 +354,7 @@ function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
|
||||
return {
|
||||
rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
|
||||
const s = asString(raw.rewriteNetwork ?? raw.network);
|
||||
return (s === 'udp' || s === 'tcp') ? s : '';
|
||||
return s === 'udp' || s === 'tcp' ? s : '';
|
||||
})(),
|
||||
rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
|
||||
rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
|
||||
@@ -415,28 +421,52 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
|
||||
const sendThrough = asString(raw.sendThrough);
|
||||
const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
|
||||
const mux = muxFromWire(raw.mux);
|
||||
const hasStream = raw.streamSettings
|
||||
&& typeof raw.streamSettings === 'object'
|
||||
&& Object.keys(raw.streamSettings as Raw).length > 0;
|
||||
const streamSettings = hasStream
|
||||
? hydrateStreamForm(raw.streamSettings as Raw)
|
||||
: undefined;
|
||||
const hasStream =
|
||||
raw.streamSettings &&
|
||||
typeof raw.streamSettings === 'object' &&
|
||||
Object.keys(raw.streamSettings as Raw).length > 0;
|
||||
const streamSettings = hasStream ? hydrateStreamForm(raw.streamSettings as Raw) : undefined;
|
||||
|
||||
let typed: OutboundFormSettings;
|
||||
switch (protocol) {
|
||||
case 'vmess': typed = { protocol: 'vmess', settings: vmessFromWire(settings) }; break;
|
||||
case 'vless': typed = { protocol: 'vless', settings: vlessFromWire(settings) }; break;
|
||||
case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
|
||||
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
|
||||
case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
|
||||
case 'http': typed = { protocol: 'http', settings: httpFromWire(settings) }; break;
|
||||
case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
|
||||
case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
|
||||
case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break;
|
||||
case 'blackhole': typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) }; break;
|
||||
case 'dns': typed = { protocol: 'dns', settings: dnsFromWire(settings) }; break;
|
||||
case 'loopback': typed = { protocol: 'loopback', settings: loopbackFromWire(settings) }; break;
|
||||
default: typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
||||
case 'vmess':
|
||||
typed = { protocol: 'vmess', settings: vmessFromWire(settings) };
|
||||
break;
|
||||
case 'vless':
|
||||
typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
||||
break;
|
||||
case 'trojan':
|
||||
typed = { protocol: 'trojan', settings: trojanFromWire(settings) };
|
||||
break;
|
||||
case 'shadowsocks':
|
||||
typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) };
|
||||
break;
|
||||
case 'socks':
|
||||
typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) };
|
||||
break;
|
||||
case 'http':
|
||||
typed = { protocol: 'http', settings: httpFromWire(settings) };
|
||||
break;
|
||||
case 'wireguard':
|
||||
typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) };
|
||||
break;
|
||||
case 'hysteria':
|
||||
typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
|
||||
break;
|
||||
case 'freedom':
|
||||
typed = { protocol: 'freedom', settings: freedomFromWire(settings) };
|
||||
break;
|
||||
case 'blackhole':
|
||||
typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) };
|
||||
break;
|
||||
case 'dns':
|
||||
typed = { protocol: 'dns', settings: dnsFromWire(settings) };
|
||||
break;
|
||||
case 'loopback':
|
||||
typed = { protocol: 'loopback', settings: loopbackFromWire(settings) };
|
||||
break;
|
||||
default:
|
||||
typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -453,11 +483,13 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
|
||||
|
||||
function vmessToWire(s: VmessOutboundFormSettings) {
|
||||
return {
|
||||
vnext: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: [{ id: s.id, security: s.security }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: [{ id: s.id, security: s.security }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -503,24 +535,28 @@ function trojanToWire(s: TrojanOutboundFormSettings) {
|
||||
|
||||
function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
|
||||
return {
|
||||
servers: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
password: s.password,
|
||||
method: s.method,
|
||||
uot: s.uot,
|
||||
UoTVersion: s.UoTVersion,
|
||||
}],
|
||||
servers: [
|
||||
{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
password: s.password,
|
||||
method: s.method,
|
||||
uot: s.uot,
|
||||
UoTVersion: s.UoTVersion,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function simpleAuthToWire(s: SimpleAuthFormSettings) {
|
||||
return {
|
||||
servers: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: s.user ? [{ user: s.user, pass: s.pass }] : [],
|
||||
}],
|
||||
servers: [
|
||||
{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: s.user ? [{ user: s.user, pass: s.pass }] : [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -536,10 +572,18 @@ function wireguardToWire(s: WireguardOutboundFormSettings) {
|
||||
return {
|
||||
mtu: s.mtu || undefined,
|
||||
secretKey: s.secretKey,
|
||||
address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
|
||||
address: s.address
|
||||
? s.address
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
domainStrategy: s.domainStrategy || undefined,
|
||||
reserved: s.reserved
|
||||
? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
|
||||
? s.reserved
|
||||
.split(',')
|
||||
.map((x) => Number(x.trim()))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
: undefined,
|
||||
peers: s.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
@@ -574,15 +618,16 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
|
||||
proxyProtocol: s.proxyProtocol || undefined,
|
||||
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
||||
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
|
||||
finalRules: s.finalRules && s.finalRules.length > 0
|
||||
? s.finalRules.map((r) => ({
|
||||
action: r.action,
|
||||
network: r.network || undefined,
|
||||
port: r.port || undefined,
|
||||
ip: r.ip.length > 0 ? r.ip : undefined,
|
||||
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
finalRules:
|
||||
s.finalRules && s.finalRules.length > 0
|
||||
? s.finalRules.map((r) => ({
|
||||
action: r.action,
|
||||
network: r.network || undefined,
|
||||
port: r.port || undefined,
|
||||
ip: r.ip.length > 0 ? r.ip : undefined,
|
||||
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -591,15 +636,16 @@ function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
|
||||
}
|
||||
|
||||
function dnsRuleToWire(r: DnsRuleForm) {
|
||||
const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action)
|
||||
? r.action
|
||||
: 'direct';
|
||||
const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action) ? r.action : 'direct';
|
||||
const result: Raw = { action };
|
||||
const qType = r.qType.trim();
|
||||
if (qType) {
|
||||
result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
|
||||
}
|
||||
const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
|
||||
const domains = r.domain
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
if (domains.length > 0) result.domain = domains;
|
||||
if (r.rCode > 0) result.rCode = r.rCode;
|
||||
return result;
|
||||
@@ -653,13 +699,13 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
|
||||
|
||||
function muxAllowed(values: OutboundFormValues): boolean {
|
||||
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
|
||||
const flow = values.protocol === 'vless'
|
||||
? (values.settings as VlessOutboundFormSettings).flow
|
||||
: '';
|
||||
const flow =
|
||||
values.protocol === 'vless' ? (values.settings as VlessOutboundFormSettings).flow : '';
|
||||
if (flow) return false;
|
||||
const network = values.streamSettings && 'network' in values.streamSettings
|
||||
? values.streamSettings.network
|
||||
: undefined;
|
||||
const network =
|
||||
values.streamSettings && 'network' in values.streamSettings
|
||||
? values.streamSettings.network
|
||||
: undefined;
|
||||
if (network === 'xhttp') return false;
|
||||
return true;
|
||||
}
|
||||
@@ -669,18 +715,42 @@ export type WireOutboundPayload = Raw;
|
||||
export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
|
||||
let settings: Raw;
|
||||
switch (values.protocol) {
|
||||
case 'vmess': settings = vmessToWire(values.settings); break;
|
||||
case 'vless': settings = vlessToWire(values.settings); break;
|
||||
case 'trojan': settings = trojanToWire(values.settings); break;
|
||||
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
|
||||
case 'socks': settings = simpleAuthToWire(values.settings); break;
|
||||
case 'http': settings = httpToWire(values.settings); break;
|
||||
case 'wireguard': settings = wireguardToWire(values.settings); break;
|
||||
case 'hysteria': settings = hysteriaToWire(values.settings); break;
|
||||
case 'freedom': settings = freedomToWire(values.settings); break;
|
||||
case 'blackhole': settings = blackholeToWire(values.settings); break;
|
||||
case 'dns': settings = dnsToWire(values.settings); break;
|
||||
case 'loopback': settings = loopbackToWire(values.settings); break;
|
||||
case 'vmess':
|
||||
settings = vmessToWire(values.settings);
|
||||
break;
|
||||
case 'vless':
|
||||
settings = vlessToWire(values.settings);
|
||||
break;
|
||||
case 'trojan':
|
||||
settings = trojanToWire(values.settings);
|
||||
break;
|
||||
case 'shadowsocks':
|
||||
settings = shadowsocksToWire(values.settings);
|
||||
break;
|
||||
case 'socks':
|
||||
settings = simpleAuthToWire(values.settings);
|
||||
break;
|
||||
case 'http':
|
||||
settings = httpToWire(values.settings);
|
||||
break;
|
||||
case 'wireguard':
|
||||
settings = wireguardToWire(values.settings);
|
||||
break;
|
||||
case 'hysteria':
|
||||
settings = hysteriaToWire(values.settings);
|
||||
break;
|
||||
case 'freedom':
|
||||
settings = freedomToWire(values.settings);
|
||||
break;
|
||||
case 'blackhole':
|
||||
settings = blackholeToWire(values.settings);
|
||||
break;
|
||||
case 'dns':
|
||||
settings = dnsToWire(values.settings);
|
||||
break;
|
||||
case 'loopback':
|
||||
settings = loopbackToWire(values.settings);
|
||||
break;
|
||||
}
|
||||
|
||||
const result: Raw = {
|
||||
|
||||
@@ -23,11 +23,23 @@ type Raw = Record<string, unknown>;
|
||||
// the same set of advanced fields when present. Keep order ~stable to
|
||||
// match the schema's authoring order so diffs read naturally.
|
||||
const XHTTP_STRING_KEYS = [
|
||||
'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement',
|
||||
'xPaddingMethod', 'sessionIDPlacement', 'sessionIDKey', 'sessionIDTable',
|
||||
'sessionIDLength', 'seqPlacement', 'seqKey', 'uplinkDataPlacement',
|
||||
'uplinkDataKey', 'scMaxEachPostBytes', 'scMinPostsIntervalMs',
|
||||
'scStreamUpServerSecs', 'uplinkHTTPMethod',
|
||||
'xPaddingBytes',
|
||||
'xPaddingKey',
|
||||
'xPaddingHeader',
|
||||
'xPaddingPlacement',
|
||||
'xPaddingMethod',
|
||||
'sessionIDPlacement',
|
||||
'sessionIDKey',
|
||||
'sessionIDTable',
|
||||
'sessionIDLength',
|
||||
'seqPlacement',
|
||||
'seqKey',
|
||||
'uplinkDataPlacement',
|
||||
'uplinkDataKey',
|
||||
'scMaxEachPostBytes',
|
||||
'scMinPostsIntervalMs',
|
||||
'scStreamUpServerSecs',
|
||||
'uplinkHTTPMethod',
|
||||
] as const;
|
||||
// Legacy share links (pre xray-core #6258) carry sessionPlacement/sessionKey.
|
||||
// Map them onto the renamed keys so old links still import. Mirrors the
|
||||
@@ -37,11 +49,11 @@ const XHTTP_LEGACY_ALIASES: Record<string, string> = {
|
||||
sessionKey: 'sessionIDKey',
|
||||
};
|
||||
const XHTTP_NUMBER_KEYS = [
|
||||
'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize',
|
||||
] as const;
|
||||
const XHTTP_BOOL_KEYS = [
|
||||
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
|
||||
'scMaxBufferedPosts',
|
||||
'serverMaxHeaderBytes',
|
||||
'uplinkChunkSize',
|
||||
] as const;
|
||||
const XHTTP_BOOL_KEYS = ['xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader'] as const;
|
||||
// Nested objects the inbound link bundles into the `extra` JSON blob
|
||||
// (and vmess JSON carries inline). The outbound form adapter expands
|
||||
// xmux into the XMUX sub-form (enableXmux) on load.
|
||||
@@ -127,8 +139,12 @@ function buildStream(network: string, security: string): Raw {
|
||||
break;
|
||||
case 'kcp':
|
||||
stream.kcpSettings = {
|
||||
mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
|
||||
cwndMultiplier: 1, maxSendingWindow: 2097152,
|
||||
mtu: 1350,
|
||||
tti: 20,
|
||||
uplinkCapacity: 5,
|
||||
downlinkCapacity: 20,
|
||||
cwndMultiplier: 1,
|
||||
maxSendingWindow: 2097152,
|
||||
};
|
||||
break;
|
||||
case 'ws':
|
||||
@@ -142,7 +158,10 @@ function buildStream(network: string, security: string): Raw {
|
||||
break;
|
||||
case 'xhttp':
|
||||
stream.xhttpSettings = {
|
||||
path: '/', host: '', mode: 'auto', headers: {},
|
||||
path: '/',
|
||||
host: '',
|
||||
mode: 'auto',
|
||||
headers: {},
|
||||
xPaddingBytes: '100-1000',
|
||||
};
|
||||
break;
|
||||
@@ -151,13 +170,21 @@ function buildStream(network: string, security: string): Raw {
|
||||
}
|
||||
if (security === 'tls') {
|
||||
stream.tlsSettings = {
|
||||
serverName: '', alpn: [], fingerprint: '',
|
||||
echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
|
||||
serverName: '',
|
||||
alpn: [],
|
||||
fingerprint: '',
|
||||
echConfigList: '',
|
||||
verifyPeerCertByName: '',
|
||||
pinnedPeerCertSha256: '',
|
||||
};
|
||||
} else if (security === 'reality') {
|
||||
stream.realitySettings = {
|
||||
publicKey: '', fingerprint: 'chrome', serverName: '',
|
||||
shortId: '', spiderX: '', mldsa65Verify: '',
|
||||
publicKey: '',
|
||||
fingerprint: 'chrome',
|
||||
serverName: '',
|
||||
shortId: '',
|
||||
spiderX: '',
|
||||
mldsa65Verify: '',
|
||||
};
|
||||
}
|
||||
return stream;
|
||||
@@ -241,12 +268,17 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
|
||||
if (!password) return;
|
||||
const finalmask = ensureFinalMask(stream);
|
||||
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
|
||||
const existing = udp.find((m) => m && typeof m === 'object' && (m as Raw).type === 'salamander') as Raw | undefined;
|
||||
const existing = udp.find(
|
||||
(m) => m && typeof m === 'object' && (m as Raw).type === 'salamander',
|
||||
) as Raw | undefined;
|
||||
if (existing) {
|
||||
const settings = (existing.settings && typeof existing.settings === 'object'
|
||||
? existing.settings
|
||||
: (existing.settings = {})) as Raw;
|
||||
if (typeof settings.password !== 'string' || settings.password.length === 0) settings.password = password;
|
||||
const settings = (
|
||||
existing.settings && typeof existing.settings === 'object'
|
||||
? existing.settings
|
||||
: (existing.settings = {})
|
||||
) as Raw;
|
||||
if (typeof settings.password !== 'string' || settings.password.length === 0)
|
||||
settings.password = password;
|
||||
return;
|
||||
}
|
||||
finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
|
||||
@@ -259,9 +291,11 @@ function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
|
||||
const ports = firstParam(params, 'mport');
|
||||
if (!ports) return;
|
||||
const finalmask = ensureFinalMask(stream);
|
||||
const quicParams = (finalmask.quicParams && typeof finalmask.quicParams === 'object'
|
||||
? finalmask.quicParams
|
||||
: (finalmask.quicParams = {})) as Raw;
|
||||
const quicParams = (
|
||||
finalmask.quicParams && typeof finalmask.quicParams === 'object'
|
||||
? finalmask.quicParams
|
||||
: (finalmask.quicParams = {})
|
||||
) as Raw;
|
||||
const existingHop = quicParams.udpHop as Raw | undefined;
|
||||
if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return;
|
||||
quicParams.udpHop = { ports, interval: '5-10' };
|
||||
@@ -371,8 +405,9 @@ export function parseVmessLink(link: string): Raw | null {
|
||||
(stream.tcpSettings as Raw).header = {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1', method: 'GET',
|
||||
path: (json.path as string ?? '/').split(',').filter(Boolean),
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: ((json.path as string) ?? '/').split(',').filter(Boolean),
|
||||
headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
|
||||
},
|
||||
};
|
||||
@@ -407,11 +442,13 @@ export function parseVmessLink(link: string): Raw | null {
|
||||
protocol: 'vmess',
|
||||
tag: typeof json.ps === 'string' ? json.ps : '',
|
||||
settings: {
|
||||
vnext: [{
|
||||
address: json.add ?? '',
|
||||
port,
|
||||
users: [{ id: json.id ?? '', security: userSecurity }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: json.add ?? '',
|
||||
port,
|
||||
users: [{ id: json.id ?? '', security: userSecurity }],
|
||||
},
|
||||
],
|
||||
},
|
||||
streamSettings: stream,
|
||||
};
|
||||
@@ -493,7 +530,11 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
||||
const hashIndex = link.indexOf('#');
|
||||
const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
|
||||
if (hashIndex >= 0) {
|
||||
try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
|
||||
try {
|
||||
remark = decodeURIComponent(link.slice(hashIndex + 1));
|
||||
} catch {
|
||||
remark = '';
|
||||
}
|
||||
}
|
||||
const queryIndex = linkNoHash.indexOf('?');
|
||||
const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
|
||||
@@ -503,10 +544,17 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
||||
if (rawUserInfo.includes(':')) {
|
||||
// SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64
|
||||
// (a literal ':' can't appear in a base64/base64url string).
|
||||
try { userInfo = decodeURIComponent(rawUserInfo); } catch { userInfo = rawUserInfo; }
|
||||
try {
|
||||
userInfo = decodeURIComponent(rawUserInfo);
|
||||
} catch {
|
||||
userInfo = rawUserInfo;
|
||||
}
|
||||
} else {
|
||||
try { userInfo = Base64.decode(rawUserInfo); }
|
||||
catch { userInfo = rawUserInfo; }
|
||||
try {
|
||||
userInfo = Base64.decode(rawUserInfo);
|
||||
} catch {
|
||||
userInfo = rawUserInfo;
|
||||
}
|
||||
}
|
||||
const hostPort = core.slice(atIndex + 1);
|
||||
const colon = hostPort.lastIndexOf(':');
|
||||
@@ -515,8 +563,11 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
||||
port = Number(hostPort.slice(colon + 1)) || 443;
|
||||
} else {
|
||||
let decoded: string;
|
||||
try { decoded = Base64.decode(core.slice('ss://'.length)); }
|
||||
catch { return null; }
|
||||
try {
|
||||
decoded = Base64.decode(core.slice('ss://'.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const at = decoded.indexOf('@');
|
||||
if (at < 0) return null;
|
||||
userInfo = decoded.slice(0, at);
|
||||
@@ -554,7 +605,9 @@ export function parseHysteria2Link(link: string): Raw | null {
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
hysteriaSettings: {
|
||||
version: 2, auth, udpIdleTimeout: 60,
|
||||
version: 2,
|
||||
auth,
|
||||
udpIdleTimeout: 60,
|
||||
},
|
||||
tlsSettings: {
|
||||
serverName: params.get('sni') ?? '',
|
||||
@@ -599,11 +652,17 @@ export function parseWireguardLink(link: string): Raw | null {
|
||||
const endpoint = host ? (port ? `${host}:${port}` : host) : '';
|
||||
|
||||
const addressRaw = firstParam(params, 'address', 'ip') ?? '';
|
||||
const address = addressRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const address = addressRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const allowedRaw = firstParam(params, 'allowedips', 'allowed_ips');
|
||||
const allowedIPs = allowedRaw
|
||||
? allowedRaw.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
? allowedRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: ['0.0.0.0/0', '::/0'];
|
||||
|
||||
const peer: Raw = {
|
||||
@@ -613,7 +672,12 @@ export function parseWireguardLink(link: string): Raw | null {
|
||||
};
|
||||
const psk = firstParam(params, 'presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
|
||||
if (psk) peer.preSharedKey = psk;
|
||||
const keepAliveRaw = firstParam(params, 'keepalive', 'persistentkeepalive', 'persistent_keepalive');
|
||||
const keepAliveRaw = firstParam(
|
||||
params,
|
||||
'keepalive',
|
||||
'persistentkeepalive',
|
||||
'persistent_keepalive',
|
||||
);
|
||||
if (keepAliveRaw !== null) {
|
||||
const k = Number(keepAliveRaw);
|
||||
if (Number.isFinite(k)) peer.keepAlive = k;
|
||||
@@ -627,7 +691,8 @@ export function parseWireguardLink(link: string): Raw | null {
|
||||
}
|
||||
const reservedRaw = firstParam(params, 'reserved');
|
||||
if (reservedRaw) {
|
||||
const reserved = reservedRaw.split(',')
|
||||
const reserved = reservedRaw
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (reserved.length > 0) settings.reserved = reserved;
|
||||
@@ -646,11 +711,11 @@ export function parseOutboundLink(link: string): Raw | null {
|
||||
const trimmed = link.trim();
|
||||
if (!trimmed) return null;
|
||||
return (
|
||||
parseVmessLink(trimmed)
|
||||
?? parseVlessLink(trimmed)
|
||||
?? parseTrojanLink(trimmed)
|
||||
?? parseShadowsocksLink(trimmed)
|
||||
?? parseHysteria2Link(trimmed)
|
||||
?? parseWireguardLink(trimmed)
|
||||
parseVmessLink(trimmed) ??
|
||||
parseVlessLink(trimmed) ??
|
||||
parseTrojanLink(trimmed) ??
|
||||
parseShadowsocksLink(trimmed) ??
|
||||
parseHysteria2Link(trimmed) ??
|
||||
parseWireguardLink(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,15 @@ const TLS_ELIGIBLE_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks'];
|
||||
const TLS_NETWORKS = ['tcp', 'ws', 'http', 'grpc', 'httpupgrade', 'xhttp'];
|
||||
const REALITY_ELIGIBLE_PROTOCOLS = ['vless', 'trojan'];
|
||||
const REALITY_NETWORKS = ['tcp', 'http', 'grpc', 'xhttp'];
|
||||
const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria', 'wireguard', 'tunnel'];
|
||||
const STREAM_PROTOCOLS = [
|
||||
'vmess',
|
||||
'vless',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'hysteria',
|
||||
'wireguard',
|
||||
'tunnel',
|
||||
];
|
||||
const VISION_FLOW = 'xtls-rprx-vision';
|
||||
const SS_2022_PREFIX = '2022';
|
||||
const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
WsStreamSettingsSchema,
|
||||
XHttpStreamSettingsSchema,
|
||||
} from '@/schemas/protocols/stream';
|
||||
import {
|
||||
RealityStreamSettingsSchema,
|
||||
TlsStreamSettingsSchema,
|
||||
} from '@/schemas/protocols/security';
|
||||
import { RealityStreamSettingsSchema, TlsStreamSettingsSchema } from '@/schemas/protocols/security';
|
||||
|
||||
const NETWORK_KEY_MAP = {
|
||||
tcp: 'tcpSettings',
|
||||
@@ -33,22 +30,33 @@ function parseOrDefault(schema: SchemaWithParse, value: unknown): unknown {
|
||||
|
||||
function networkSchemaFor(network: string): SchemaWithParse | null {
|
||||
switch (network) {
|
||||
case 'tcp': return TcpStreamSettingsSchema;
|
||||
case 'kcp': return KcpStreamSettingsSchema;
|
||||
case 'ws': return WsStreamSettingsSchema;
|
||||
case 'grpc': return GrpcStreamSettingsSchema;
|
||||
case 'httpupgrade': return HttpUpgradeStreamSettingsSchema;
|
||||
case 'xhttp': return XHttpStreamSettingsSchema;
|
||||
case 'hysteria': return HysteriaStreamSettingsSchema;
|
||||
default: return null;
|
||||
case 'tcp':
|
||||
return TcpStreamSettingsSchema;
|
||||
case 'kcp':
|
||||
return KcpStreamSettingsSchema;
|
||||
case 'ws':
|
||||
return WsStreamSettingsSchema;
|
||||
case 'grpc':
|
||||
return GrpcStreamSettingsSchema;
|
||||
case 'httpupgrade':
|
||||
return HttpUpgradeStreamSettingsSchema;
|
||||
case 'xhttp':
|
||||
return XHttpStreamSettingsSchema;
|
||||
case 'hysteria':
|
||||
return HysteriaStreamSettingsSchema;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function securitySchemaFor(security: string): { key: string; schema: SchemaWithParse } | null {
|
||||
switch (security) {
|
||||
case 'tls': return { key: 'tlsSettings', schema: TlsStreamSettingsSchema };
|
||||
case 'reality': return { key: 'realitySettings', schema: RealityStreamSettingsSchema };
|
||||
default: return null;
|
||||
case 'tls':
|
||||
return { key: 'tlsSettings', schema: TlsStreamSettingsSchema };
|
||||
case 'reality':
|
||||
return { key: 'realitySettings', schema: RealityStreamSettingsSchema };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,9 +153,7 @@ export function validateRealityMaxClientVer(max: string, min: string): string |
|
||||
if (!maxParts || !minParts) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (maxParts[i] !== minParts[i]) {
|
||||
return maxParts[i] < minParts[i]
|
||||
? 'pages.inbounds.form.maxClientVerBelowMin'
|
||||
: undefined;
|
||||
return maxParts[i] < minParts[i] ? 'pages.inbounds.form.maxClientVerBelowMin' : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -255,12 +253,7 @@ export function normalizeXhttpForWire(
|
||||
|
||||
if (out.xPaddingObfsMode !== true) {
|
||||
delete out.xPaddingObfsMode;
|
||||
dropEmptyStrings(out, [
|
||||
'xPaddingKey',
|
||||
'xPaddingHeader',
|
||||
'xPaddingPlacement',
|
||||
'xPaddingMethod',
|
||||
]);
|
||||
dropEmptyStrings(out, ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod']);
|
||||
}
|
||||
|
||||
if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
|
||||
@@ -297,13 +290,7 @@ export function normalizeSockoptForWire(
|
||||
'mark',
|
||||
]);
|
||||
|
||||
dropFalseFlags(out, [
|
||||
'acceptProxyProtocol',
|
||||
'tcpFastOpen',
|
||||
'tcpMptcp',
|
||||
'penetrate',
|
||||
'V6Only',
|
||||
]);
|
||||
dropFalseFlags(out, ['acceptProxyProtocol', 'tcpFastOpen', 'tcpMptcp', 'penetrate', 'V6Only']);
|
||||
|
||||
if (out.tproxy === 'off') delete out.tproxy;
|
||||
if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
|
||||
|
||||
+203
-204
@@ -5,227 +5,226 @@ import { Protocols } from '@/schemas/primitives';
|
||||
export type RawJsonField = string | Record<string, unknown> | unknown[];
|
||||
|
||||
export interface ClientStats {
|
||||
email: string;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
expiryTime: number;
|
||||
enable?: boolean;
|
||||
inboundId?: number;
|
||||
reset?: number;
|
||||
email: string;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
expiryTime: number;
|
||||
enable?: boolean;
|
||||
inboundId?: number;
|
||||
reset?: number;
|
||||
}
|
||||
|
||||
export interface FallbackParentRef {
|
||||
masterId: number;
|
||||
path: string;
|
||||
masterId: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type DBInboundInit = Partial<{
|
||||
id: number;
|
||||
userId: number;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
settings: RawJsonField;
|
||||
streamSettings: RawJsonField;
|
||||
tag: string;
|
||||
sniffing: RawJsonField;
|
||||
clientStats: ClientStats[];
|
||||
nodeId: number | null;
|
||||
shareAddrStrategy: string;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
disableFlow: boolean;
|
||||
originNodeGuid: string;
|
||||
fallbackParent: FallbackParentRef | null;
|
||||
id: number;
|
||||
userId: number;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
settings: RawJsonField;
|
||||
streamSettings: RawJsonField;
|
||||
tag: string;
|
||||
sniffing: RawJsonField;
|
||||
clientStats: ClientStats[];
|
||||
nodeId: number | null;
|
||||
shareAddrStrategy: string;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
disableFlow: boolean;
|
||||
originNodeGuid: string;
|
||||
fallbackParent: FallbackParentRef | null;
|
||||
}>;
|
||||
|
||||
export function coerceInboundJsonField(value: unknown): Record<string, unknown> {
|
||||
if (value == null) return {};
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value !== 'string') return {};
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
if (value == null) return {};
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value !== 'string') return {};
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export class DBInbound {
|
||||
id: number;
|
||||
userId: number;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
id: number;
|
||||
userId: number;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
settings: RawJsonField;
|
||||
streamSettings: RawJsonField;
|
||||
tag: string;
|
||||
sniffing: RawJsonField;
|
||||
clientStats: ClientStats[];
|
||||
nodeId: number | null;
|
||||
shareAddrStrategy: string;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
disableFlow: boolean;
|
||||
originNodeGuid: string;
|
||||
fallbackParent: FallbackParentRef | null;
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
settings: RawJsonField;
|
||||
streamSettings: RawJsonField;
|
||||
tag: string;
|
||||
sniffing: RawJsonField;
|
||||
clientStats: ClientStats[];
|
||||
nodeId: number | null;
|
||||
shareAddrStrategy: string;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
disableFlow: boolean;
|
||||
originNodeGuid: string;
|
||||
fallbackParent: FallbackParentRef | null;
|
||||
|
||||
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
||||
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
||||
|
||||
constructor(data?: DBInboundInit) {
|
||||
this.id = 0;
|
||||
this.userId = 0;
|
||||
this.up = 0;
|
||||
this.down = 0;
|
||||
this.total = 0;
|
||||
this.remark = "";
|
||||
this.enable = true;
|
||||
this.expiryTime = 0;
|
||||
this.trafficReset = "never";
|
||||
this.trafficResetDay = 1;
|
||||
this.lastTrafficResetTime = 0;
|
||||
constructor(data?: DBInboundInit) {
|
||||
this.id = 0;
|
||||
this.userId = 0;
|
||||
this.up = 0;
|
||||
this.down = 0;
|
||||
this.total = 0;
|
||||
this.remark = '';
|
||||
this.enable = true;
|
||||
this.expiryTime = 0;
|
||||
this.trafficReset = 'never';
|
||||
this.trafficResetDay = 1;
|
||||
this.lastTrafficResetTime = 0;
|
||||
|
||||
this.listen = "";
|
||||
this.port = 0;
|
||||
this.protocol = "";
|
||||
this.settings = "";
|
||||
this.streamSettings = "";
|
||||
this.tag = "";
|
||||
this.sniffing = "";
|
||||
this.clientStats = [];
|
||||
this.nodeId = null;
|
||||
this.shareAddrStrategy = "node";
|
||||
this.shareAddr = "";
|
||||
this.subSortIndex = 1;
|
||||
this.disableFlow = false;
|
||||
this.originNodeGuid = "";
|
||||
this.fallbackParent = null;
|
||||
if (data == null) {
|
||||
return;
|
||||
this.listen = '';
|
||||
this.port = 0;
|
||||
this.protocol = '';
|
||||
this.settings = '';
|
||||
this.streamSettings = '';
|
||||
this.tag = '';
|
||||
this.sniffing = '';
|
||||
this.clientStats = [];
|
||||
this.nodeId = null;
|
||||
this.shareAddrStrategy = 'node';
|
||||
this.shareAddr = '';
|
||||
this.subSortIndex = 1;
|
||||
this.disableFlow = false;
|
||||
this.originNodeGuid = '';
|
||||
this.fallbackParent = null;
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
|
||||
get totalGB(): number {
|
||||
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set totalGB(gb: number) {
|
||||
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
|
||||
get isVMess() {
|
||||
return this.protocol === Protocols.VMESS;
|
||||
}
|
||||
|
||||
get isVLess() {
|
||||
return this.protocol === Protocols.VLESS;
|
||||
}
|
||||
|
||||
get isTrojan() {
|
||||
return this.protocol === Protocols.TROJAN;
|
||||
}
|
||||
|
||||
get isSS() {
|
||||
return this.protocol === Protocols.SHADOWSOCKS;
|
||||
}
|
||||
|
||||
get isMixed() {
|
||||
return this.protocol === Protocols.MIXED;
|
||||
}
|
||||
|
||||
get isHTTP() {
|
||||
return this.protocol === Protocols.HTTP;
|
||||
}
|
||||
|
||||
get isWireguard() {
|
||||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
get isTunnel() {
|
||||
return this.protocol === Protocols.TUNNEL;
|
||||
}
|
||||
|
||||
get address(): string {
|
||||
let address = location.hostname;
|
||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== '0.0.0.0') {
|
||||
address = this.listen;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
get _expiryTime(): Dayjs | null {
|
||||
if (this.expiryTime === 0) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t: Dayjs | null | undefined) {
|
||||
if (t == null) {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
get isExpiry(): boolean {
|
||||
return this.expiryTime < new Date().getTime();
|
||||
}
|
||||
|
||||
invalidateCache(): void {
|
||||
this._clientStatsMap = null;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...(this as unknown as Record<string, unknown>) };
|
||||
delete out._clientStatsMap;
|
||||
return out;
|
||||
}
|
||||
|
||||
getClientStats(email: string): ClientStats | undefined {
|
||||
if (!this._clientStatsMap) {
|
||||
this._clientStatsMap = new Map();
|
||||
if (Array.isArray(this.clientStats)) {
|
||||
for (const stats of this.clientStats) {
|
||||
if (stats && stats.email) {
|
||||
this._clientStatsMap.set(stats.email, stats);
|
||||
}
|
||||
}
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
get totalGB(): number {
|
||||
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set totalGB(gb: number) {
|
||||
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
|
||||
get isVMess() {
|
||||
return this.protocol === Protocols.VMESS;
|
||||
}
|
||||
|
||||
get isVLess() {
|
||||
return this.protocol === Protocols.VLESS;
|
||||
}
|
||||
|
||||
get isTrojan() {
|
||||
return this.protocol === Protocols.TROJAN;
|
||||
}
|
||||
|
||||
get isSS() {
|
||||
return this.protocol === Protocols.SHADOWSOCKS;
|
||||
}
|
||||
|
||||
get isMixed() {
|
||||
return this.protocol === Protocols.MIXED;
|
||||
}
|
||||
|
||||
get isHTTP() {
|
||||
return this.protocol === Protocols.HTTP;
|
||||
}
|
||||
|
||||
get isWireguard() {
|
||||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
get isTunnel() {
|
||||
return this.protocol === Protocols.TUNNEL;
|
||||
}
|
||||
|
||||
get address(): string {
|
||||
let address = location.hostname;
|
||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||
address = this.listen;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
get _expiryTime(): Dayjs | null {
|
||||
if (this.expiryTime === 0) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t: Dayjs | null | undefined) {
|
||||
if (t == null) {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
get isExpiry(): boolean {
|
||||
return this.expiryTime < new Date().getTime();
|
||||
}
|
||||
|
||||
invalidateCache(): void {
|
||||
this._clientStatsMap = null;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...(this as unknown as Record<string, unknown>) };
|
||||
delete out._clientStatsMap;
|
||||
return out;
|
||||
}
|
||||
|
||||
getClientStats(email: string): ClientStats | undefined {
|
||||
if (!this._clientStatsMap) {
|
||||
this._clientStatsMap = new Map();
|
||||
if (Array.isArray(this.clientStats)) {
|
||||
for (const stats of this.clientStats) {
|
||||
if (stats && stats.email) {
|
||||
this._clientStatsMap.set(stats.email, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._clientStatsMap.get(email);
|
||||
}
|
||||
|
||||
return this._clientStatsMap.get(email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,9 @@ export class AllSetting {
|
||||
const cpu = Math.round(Number(this.tgCpu));
|
||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||
this.outboundDownThreshold = Number.isFinite(threshold) ? Math.min(100, Math.max(1, threshold)) : 3;
|
||||
this.outboundDownThreshold = Number.isFinite(threshold)
|
||||
? Math.min(100, Math.max(1, threshold))
|
||||
: 3;
|
||||
}
|
||||
|
||||
equals(other: AllSetting): boolean {
|
||||
|
||||
@@ -152,11 +152,11 @@
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui input[type=text],
|
||||
.api-docs-page.is-dark .swagger-ui input[type=password],
|
||||
.api-docs-page.is-dark .swagger-ui input[type=search],
|
||||
.api-docs-page.is-dark .swagger-ui input[type=email],
|
||||
.api-docs-page.is-dark .swagger-ui input[type=file],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='text'],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='password'],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='search'],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='email'],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='file'],
|
||||
.api-docs-page.is-dark .swagger-ui textarea {
|
||||
background: var(--sw-bg-input);
|
||||
color: var(--sw-text);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,9 @@ export default function BulkAddToGroupModal({
|
||||
const result = await onSubmit(next);
|
||||
if (result) {
|
||||
const affected = result.affected ?? 0;
|
||||
messageApi.success(t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }));
|
||||
messageApi.success(
|
||||
t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }),
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -7,7 +7,15 @@ import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkAttachResult } from '@/schemas/client';
|
||||
|
||||
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
|
||||
const MULTI_USER_PROTOCOLS = new Set([
|
||||
'vmess',
|
||||
'vless',
|
||||
'trojan',
|
||||
'hysteria',
|
||||
'shadowsocks',
|
||||
'wireguard',
|
||||
'mtproto',
|
||||
]);
|
||||
|
||||
interface BulkAttachInboundsModalProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -7,7 +7,15 @@ import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkDetachResult } from '@/schemas/client';
|
||||
|
||||
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
|
||||
const MULTI_USER_PROTOCOLS = new Set([
|
||||
'vmess',
|
||||
'vless',
|
||||
'trojan',
|
||||
'hysteria',
|
||||
'shadowsocks',
|
||||
'wireguard',
|
||||
'mtproto',
|
||||
]);
|
||||
|
||||
interface BulkDetachInboundsModalProps {
|
||||
open: boolean;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user