mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 17:52:10 +03:00
Compare commits
95 Commits
v3.8.0
..
dev-latest
| Author | SHA1 | Date | |
|---|---|---|---|
| 09617f04f5 | |||
| 044e2926a0 | |||
| 75f3702dd3 | |||
| 8f47b53879 | |||
| a33b2341e9 | |||
| 3fc3992a46 | |||
| ff322f901a | |||
| 249b38e156 | |||
| 18b337d131 | |||
| f6a1a3bbd1 | |||
| a579357343 | |||
| 7aa5fc085f | |||
| 6f40a75909 | |||
| 12d51d7195 | |||
| 33a469315a | |||
| ac43b19cfa | |||
| 0ef94b686e | |||
| 71e38367c1 | |||
| bd9ccde1f4 | |||
| 9672249edb | |||
| 5e15120cec | |||
| 94fa317e76 | |||
| 788b76c544 | |||
| 6ac0c88084 | |||
| 6ab718f813 | |||
| 8979072bd9 | |||
| f3b100282a | |||
| bd01f923fb | |||
| b3a5be9da4 | |||
| 8f1201553e | |||
| d42e2133c7 | |||
| a2ca023336 | |||
| 3fe92df7ad | |||
| 169cd86e00 | |||
| 66df77665f | |||
| c54c28d92d | |||
| 5d41e65a3c | |||
| 0dec3d65ba | |||
| 07ee638a50 | |||
| ee2ff48c81 | |||
| 3b9ca47a4e | |||
| c0c0136037 | |||
| d86a3def85 | |||
| dcaadd4857 | |||
| fd7b3559bc | |||
| 89e200ead4 | |||
| a03228c455 | |||
| 86302d2f2d | |||
| 95f19b192f | |||
| 1c0ce80e8e | |||
| f8db7f6c29 | |||
| 536f9a6338 | |||
| d59b77bcdb | |||
| 17e89db979 | |||
| 040d01c5dc | |||
| b78dd82869 | |||
| 7ef22f94c9 | |||
| ec9fbae645 | |||
| e26cf1d3ed | |||
| 01ce2bcecb | |||
| c9e62451e6 | |||
| 5008906c4c | |||
| 5fe4f241c1 | |||
| e8bab17c2f | |||
| 14b92fbcff | |||
| 1d85ef138e | |||
| 3fa44915c1 | |||
| 7fc86f87de | |||
| 3c1498d806 | |||
| d1c4e0261b | |||
| bc49c1a68f | |||
| 3c8cf35734 | |||
| dea7cd9cc1 | |||
| 56bb876d8d | |||
| eb11e8c85a | |||
| a84bbeab2e | |||
| ea66aa4971 | |||
| cfa8350d10 | |||
| af466b6a24 | |||
| 789a03065a | |||
| bc424f0968 | |||
| ac3fc12077 | |||
| d9c7c76fb0 | |||
| d440c2b932 | |||
| e790f46757 | |||
| d089adeeea | |||
| 4a8fdceed6 | |||
| 574caa63e9 | |||
| baef3cdd07 | |||
| 43e64993fc | |||
| d52b598abf | |||
| 2d8d304850 | |||
| a036ddd66f | |||
| 78ab7a9246 | |||
| a810f497e6 |
+3
-6
@@ -1,6 +1,3 @@
|
|||||||
*.sh text eol=lf
|
# LF in every checkout, Windows included: format-check, the msw worker check and
|
||||||
frontend/src/generated/** text eol=lf
|
# tests that parse repo files compare bytes, so a CRLF working copy fails them.
|
||||||
frontend/public/openapi.json text eol=lf
|
* text=auto eol=lf
|
||||||
frontend/src/test/__snapshots__/** text eol=lf
|
|
||||||
*.go text eol=lf
|
|
||||||
deploy/**/*.yaml text eol=lf
|
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ question it already answers.
|
|||||||
subtests and `t.Helper()` on helpers. An assertion must pin the exact value,
|
subtests and `t.Helper()` on helpers. An assertion must pin the exact value,
|
||||||
typed error or emitted string — `err != nil` and `len(x) > 0` are findings,
|
typed error or emitted string — `err != nil` and `len(x) > 0` are findings,
|
||||||
not nits. Prefer real dependencies: a throwaway DB via
|
not nits. Prefer real dependencies: a throwaway DB via
|
||||||
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` with `t.Cleanup`, and
|
`dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))`
|
||||||
`httptest` for HTTP. `internal/sub`'s `initSubDB(t)` is the template.
|
(`internal/database/dbtest`), and `httptest` for HTTP. `internal/sub`'s `initSubDB(t)` is the template.
|
||||||
A test must FAIL without its fix; one that passes either way certifies
|
A test must FAIL without its fix; one that passes either way certifies
|
||||||
nothing and then gets cited as proof the fix works.
|
nothing and then gets cited as proof the fix works.
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,12 @@ jobs:
|
|||||||
- name: PostgreSQL schema and migration tests
|
- name: PostgreSQL schema and migration tests
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
|
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
|
||||||
# Both must pass. Counting, not SKIP-matching: renaming either test would
|
# All must pass. Counting, not SKIP-matching: renaming a test would
|
||||||
# otherwise leave this step green while testing nothing.
|
# otherwise leave this step green while testing nothing.
|
||||||
passed=$(grep -c -- '--- PASS' /tmp/postgres-schema.log || true)
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
|
||||||
if [ "$passed" -lt 2 ]; then
|
if [ "$passed" -lt 3 ]; then
|
||||||
echo "expected 2 passing PostgreSQL schema tests, got $passed" >&2
|
echo "expected at least 3 passing PostgreSQL schema tests, got $passed" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ jobs:
|
|||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
allowed_non_write_users: "*"
|
allowed_non_write_users: "*"
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--model claude-opus-5
|
--model claude-opus-5-5
|
||||||
--effort xhigh
|
--effort medium
|
||||||
--max-turns 300
|
--max-turns 300
|
||||||
--allowedTools "Bash(gh label list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh issue comment ${{ github.event.issue.number }}:*),Bash(gh issue edit ${{ github.event.issue.number }} --add-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --remove-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --title:*),Bash(gh issue close ${{ github.event.issue.number }}:*),Bash(gh search issues:*),Bash(gh search commits:*),Bash(gh search prs:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh release list:*),Bash(gh release view:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git ls-tree:*),Bash(git tag:*),Read,Glob,Grep,Write(//tmp/**),Edit(//tmp/**)"
|
--allowedTools "Bash(gh label list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh issue comment ${{ github.event.issue.number }}:*),Bash(gh issue edit ${{ github.event.issue.number }} --add-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --remove-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --title:*),Bash(gh issue close ${{ github.event.issue.number }}:*),Bash(gh search issues:*),Bash(gh search commits:*),Bash(gh search prs:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh release list:*),Bash(gh release view:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git ls-tree:*),Bash(git tag:*),Read,Glob,Grep,Write(//tmp/**),Edit(//tmp/**)"
|
||||||
--disallowedTools "Read(//**/.git/**),Edit(//**/.git/**)"
|
--disallowedTools "Read(//**/.git/**),Edit(//**/.git/**)"
|
||||||
@@ -437,8 +437,24 @@ jobs:
|
|||||||
path: ${{ runner.temp }}/claude-execution-output.json
|
path: ${{ runner.temp }}/claude-execution-output.json
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: Fail if the analysis posted no reply
|
# A refused credential ends the action with exit 0, so the step below cannot
|
||||||
|
# tell it from a reply that landed: the transcript is the only place it appears.
|
||||||
|
- name: Report an analysis the credential refused
|
||||||
|
id: refused
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
env:
|
||||||
|
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
|
||||||
|
ISSUE: ${{ github.event.issue.number }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -f "$TRANSCRIPT" ] || exit 0
|
||||||
|
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|
||||||
|
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|
||||||
|
|| exit 0
|
||||||
|
echo "skipped=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::warning::No analysis of #${ISSUE}: the Claude credential was refused, so this issue was not examined."
|
||||||
|
- name: Fail if the analysis posted no reply
|
||||||
|
if: ${{ !cancelled() && steps.refused.outputs.skipped != 'true' }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ jobs:
|
|||||||
# allowedTools only pre-approves; it denies nothing. Only the deny list
|
# allowedTools only pre-approves; it denies nothing. Only the deny list
|
||||||
# stops the review executing what it just checked out, or delegating.
|
# stops the review executing what it just checked out, or delegating.
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--model claude-opus-5
|
--model claude-opus-5-5
|
||||||
--effort xhigh
|
--effort medium
|
||||||
--max-turns 300
|
--max-turns 300
|
||||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment ${{ env.PR }}:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch"
|
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment ${{ env.PR }}:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch"
|
||||||
--disallowedTools "Agent,Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit"
|
--disallowedTools "Agent,Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit"
|
||||||
@@ -228,10 +228,25 @@ jobs:
|
|||||||
echo "skipped=true" >> "$GITHUB_OUTPUT"
|
echo "skipped=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::No review of #${PR}: ${reason}."
|
echo "::notice::No review of #${PR}: ${reason}."
|
||||||
gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`."
|
gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`."
|
||||||
|
# A refused credential ends the action with exit 0, so the step above never
|
||||||
|
# sees it: the transcript is the only place that refusal appears.
|
||||||
|
- name: Report a review the credential refused
|
||||||
|
id: refused
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
env:
|
||||||
|
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -f "$TRANSCRIPT" ] || exit 0
|
||||||
|
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|
||||||
|
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|
||||||
|
|| exit 0
|
||||||
|
echo "skipped=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::warning::No review of #${PR}: the Claude credential was refused, so nothing in this pull request was examined."
|
||||||
# updated_at, not created_at: a re-review may edit its earlier comment.
|
# updated_at, not created_at: a re-review may edit its earlier comment.
|
||||||
# --paginate prints one jq count per page, so the pages are summed.
|
# --paginate prints one jq count per page, so the pages are summed.
|
||||||
- name: Fail if the review posted nothing
|
- name: Fail if the review posted nothing
|
||||||
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }}
|
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' && steps.refused.outputs.skipped != 'true' }}
|
||||||
env:
|
env:
|
||||||
HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
|
HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
|
||||||
STARTED_AT: ${{ steps.started.outputs.at }}
|
STARTED_AT: ${{ steps.started.outputs.at }}
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ on:
|
|||||||
- "go.mod"
|
- "go.mod"
|
||||||
- "go.sum"
|
- "go.sum"
|
||||||
- "frontend/**"
|
- "frontend/**"
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "**.go"
|
|
||||||
- "go.mod"
|
|
||||||
- "go.sum"
|
|
||||||
- "frontend/**"
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "18 2 * * 2"
|
- cron: "18 2 * * 2"
|
||||||
|
|
||||||
@@ -24,8 +18,6 @@ jobs:
|
|||||||
analyze:
|
analyze:
|
||||||
name: Analyze (${{ matrix.language }})
|
name: Analyze (${{ matrix.language }})
|
||||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||||
env:
|
|
||||||
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
|
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write
|
||||||
packages: read
|
packages: read
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ name: Release 3X-UI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
# Only main (dev channel) and version tags ship binaries; build any other
|
||||||
|
# branch on demand via workflow_dispatch.
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "**"
|
- main
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*.*.*"
|
||||||
paths:
|
paths:
|
||||||
@@ -17,17 +19,6 @@ on:
|
|||||||
- "x-ui.service.arch"
|
- "x-ui.service.arch"
|
||||||
- "x-ui.service.rhel"
|
- "x-ui.service.rhel"
|
||||||
- ".github/workflows/release.yml"
|
- ".github/workflows/release.yml"
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "**.go"
|
|
||||||
- "go.mod"
|
|
||||||
- "go.sum"
|
|
||||||
- "**.sh"
|
|
||||||
- "frontend/**"
|
|
||||||
- "x-ui.service.debian"
|
|
||||||
- "x-ui.service.arch"
|
|
||||||
- "x-ui.service.rhel"
|
|
||||||
- ".github/workflows/release.yml"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
name: Deploy Smoke Tests
|
|
||||||
|
|
||||||
# Container smoke test for the unattended (cloud-init) install path.
|
|
||||||
# Runs when the install/deploy assets change on a branch push or PR, and
|
|
||||||
# again after a release-tag build finishes uploading its assets — passing the
|
|
||||||
# tag as an explicit version, so the green result verifies the release
|
|
||||||
# actually being shipped. That job deliberately runs the script from the
|
|
||||||
# default branch rather than checking out the tag: workflow_run executes in
|
|
||||||
# main's cache scope, so executing checked-out code there is a cache-poisoning
|
|
||||||
# surface (CodeQL actions/cache-poisoning/poisonable-step), and users pipe
|
|
||||||
# main's install.sh anyway.
|
|
||||||
# Tag pushes must NOT trigger the unpinned job directly: at that moment
|
|
||||||
# releases/latest still points at the previous release (#5756), and a `paths`
|
|
||||||
# filter alone cannot exclude them because a brand-new tag ref has no diff
|
|
||||||
# base, so it runs on every tag push.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "**"
|
|
||||||
paths:
|
|
||||||
- "install.sh"
|
|
||||||
- "deploy/**"
|
|
||||||
- ".github/workflows/smoke.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "install.sh"
|
|
||||||
- "deploy/**"
|
|
||||||
- ".github/workflows/smoke.yml"
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["Release 3X-UI"]
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
noninteractive-install:
|
|
||||||
if: github.event_name != 'workflow_run'
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- name: Non-interactive install smoke test
|
|
||||||
run: bash deploy/test/smoke-noninteractive.sh
|
|
||||||
|
|
||||||
release-tag-install:
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'workflow_run' &&
|
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
|
||||||
github.event.workflow_run.event == 'push' &&
|
|
||||||
startsWith(github.event.workflow_run.head_branch, 'v') &&
|
|
||||||
contains(github.event.workflow_run.head_branch, '.')
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- name: Pinned release install smoke test
|
|
||||||
env:
|
|
||||||
XUI_SMOKE_VERSION: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
run: bash deploy/test/smoke-noninteractive.sh "$XUI_SMOKE_VERSION"
|
|
||||||
@@ -75,11 +75,18 @@ file locations when it can answer in one hop.
|
|||||||
share-link or install-command output changes.
|
share-link or install-command output changes.
|
||||||
|
|
||||||
## Hard rules (non-negotiable)
|
## Hard rules (non-negotiable)
|
||||||
- Fix size must match bug size. Find the root cause, then make the SMALLEST
|
- Correct fix over small fix. Find the root cause and fix it the right way, however
|
||||||
change that removes it — a one-line guard beats a new subsystem. A small bug
|
much code that takes. Size the change by what the correct fix needs, never by
|
||||||
does not earn new columns, jobs, abstractions, config knobs or helper layers.
|
line count: when the right fix spans many files, or needs a migration, a shared
|
||||||
If a fix genuinely needs new architecture, say so and get agreement first;
|
helper or a new abstraction, write it. A guard that hides the symptom while the
|
||||||
never ship it unasked next to the fix.
|
cause survives is the wrong fix, however small. Two limits remain:
|
||||||
|
- Everything added must be something the correct fix needs. No speculative
|
||||||
|
knobs, unused extension points or "while I was here" rewrites. Unrelated
|
||||||
|
refactors and cleanups go in their own commit.
|
||||||
|
- Stop and ask only when the right fix needs a decision the code cannot answer:
|
||||||
|
a deliberate user-visible behaviour change, or two sound designs with a real
|
||||||
|
trade-off. Ask with a recommendation. Size alone is never a reason to stop,
|
||||||
|
defer or ship a smaller patch.
|
||||||
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
|
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
|
||||||
carry the meaning first and rename rather than annotate; spend the 2 lines on
|
carry the meaning first and rename rather than annotate; spend the 2 lines on
|
||||||
the *why* a name cannot hold — an invariant, an issue number, a non-obvious
|
the *why* a name cannot hold — an invariant, an issue number, a non-obvious
|
||||||
@@ -113,19 +120,45 @@ file locations when it can answer in one hop.
|
|||||||
explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
|
explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
|
||||||
`docs`, `style`.
|
`docs`, `style`.
|
||||||
|
|
||||||
|
## Tests: TDD, and only tests that can fail (Go and frontend)
|
||||||
|
- Work red → green → refactor.
|
||||||
|
- Bug: turn the reproduction into a test first, and watch it fail for the
|
||||||
|
reported reason.
|
||||||
|
- Feature: write the test for the first behaviour before writing its code.
|
||||||
|
- Then write the code that makes it pass, and refactor with the suite green.
|
||||||
|
|
||||||
|
If a test was written after the code, prove it anyway: revert the code, watch
|
||||||
|
the test go red, then restore. A test that passes either way is worse than no
|
||||||
|
test. It certifies nothing, and then gets cited as proof the fix works.
|
||||||
|
- Every test must name the failure it catches. When no test can reach a change
|
||||||
|
(workflow YAML, pure wiring, layout), say so and name the command that
|
||||||
|
demonstrates it. Never write a stand-in test.
|
||||||
|
- Fake tests are forbidden. Delete any you write or meet in the code you touch:
|
||||||
|
- tests of a getter, a constant, a rename, a pure map lookup, or an input the
|
||||||
|
function can never receive;
|
||||||
|
- tests that restate the implementation, such as recomputing the expected
|
||||||
|
value with the same formula or asserting that a mock was called exactly the
|
||||||
|
way the code calls it;
|
||||||
|
- mocking the unit under test, or mocking so much around it that the real
|
||||||
|
code path never runs;
|
||||||
|
- assertions too weak to fail: `err != nil`, `len > 0`, `toBeDefined()`, or
|
||||||
|
`not.toThrow()` alone;
|
||||||
|
- golden files or snapshots regenerated to match whatever the code now outputs;
|
||||||
|
- extra cases that exercise no distinct branch, and tests written to raise
|
||||||
|
coverage.
|
||||||
|
|
||||||
|
One real test that drives the bug through the actual code path beats five
|
||||||
|
that restate the code.
|
||||||
|
|
||||||
## Go conventions
|
## Go conventions
|
||||||
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
|
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
|
||||||
`t.Helper()` on helpers. Assert the exact value / typed error / emitted
|
`t.Helper()` on helpers. Assert the exact value / typed error / emitted
|
||||||
string, never just `err != nil`. Prefer real deps over mocks: throwaway DB via
|
string, never just `err != nil`. Prefer real deps over mocks: throwaway DB via
|
||||||
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
|
`dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))`
|
||||||
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
|
(`internal/database/dbtest`: copies a once-migrated template and registers
|
||||||
`internal/sub`'s `initSubDB(t)` is the template.
|
`CloseDB` cleanup; a fresh `database.InitDB` costs ~7x more, ~850ms under
|
||||||
- A test must fail without its fix. Write it, revert the fix, watch it go red,
|
`-race`); `httptest` for HTTP. Keep `database.InitDB` for reopening a file or
|
||||||
restore. A test that passes either way is worse than no test: it certifies
|
migrating a hand-built legacy DB. `internal/sub`'s `initSubDB(t)` is the template.
|
||||||
nothing and then gets cited as proof the fix works.
|
|
||||||
- Test what can actually break. No test for a getter, a constant, a rename, a
|
|
||||||
pure map lookup, or inputs the function can never receive. One real test that
|
|
||||||
drives the bug through the actual code path beats five that restate the code.
|
|
||||||
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
|
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
|
||||||
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
|
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
|
||||||
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a
|
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a
|
||||||
@@ -136,7 +169,7 @@ file locations when it can answer in one hop.
|
|||||||
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
|
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
|
||||||
`src/schemas/` are the source of truth; infer types with `z.infer`, never
|
`src/schemas/` are the source of truth; infer types with `z.infer`, never
|
||||||
hand-write. Do not edit `src/generated/`.
|
hand-write. Do not edit `src/generated/`.
|
||||||
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
|
- Node 26 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
|
||||||
stripping; Node 22 dies with `ERR_UNKNOWN_FILE_EXTENSION`. `npm test` includes
|
stripping; Node 22 dies with `ERR_UNKNOWN_FILE_EXTENSION`. `npm test` includes
|
||||||
a headless-Chromium Storybook project, so run
|
a headless-Chromium Storybook project, so run
|
||||||
`npx playwright install --with-deps chromium` once or `make verify` fails.
|
`npx playwright install --with-deps chromium` once or `make verify` fails.
|
||||||
|
|||||||
+8
-2
@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Go 1.27+** (the version pinned in `go.mod`)
|
- **Go 1.27+** (the version pinned in `go.mod`)
|
||||||
- **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
|
- **Node.js 26** (the version pinned in `.nvmrc`) and npm 11+ (for the React frontend)
|
||||||
- **Git**
|
- **Git**
|
||||||
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
|
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
|
||||||
|
|
||||||
@@ -243,11 +243,17 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
|
|||||||
|
|
||||||
Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
|
Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
|
||||||
|
|
||||||
|
### Test first, and only tests that can fail
|
||||||
|
|
||||||
|
- **Red → green → refactor.** Write the test before the code. For a bug, the test reproduces the report; for a feature, it covers the first behaviour. Watch it fail, write the code that makes it pass, then refactor with the suite green.
|
||||||
|
- **Every test catches a named failure.** Don't test getters, constants or renames. Don't restate the implementation, mock the unit under test, write assertions too weak to fail, or regenerate snapshots to match whatever the code now outputs.
|
||||||
|
- **Fix the root cause the right way**, even when that takes more code. A small patch that hides the symptom is not a fix.
|
||||||
|
|
||||||
### Go conventions
|
### Go conventions
|
||||||
|
|
||||||
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
|
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
|
||||||
- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
|
- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
|
||||||
- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
|
- **Real dependencies over mocks.** Get a throwaway DB with `dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))` from `internal/database/dbtest`: it copies a once-migrated template (migrating from scratch per test is ~7x slower, worst under `-race`) and closes the DB before `t.TempDir` cleanup (Windows-safe). Keep `database.InitDB` for reopening an existing file or migrating a hand-built legacy DB. Use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
|
||||||
|
|
||||||
### Running
|
### Running
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||||
)
|
)
|
||||||
@@ -16,10 +17,7 @@ import (
|
|||||||
func newTokenCLIEnv(t *testing.T) {
|
func newTokenCLIEnv(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||||
if err := database.InitDB(config.GetDBPath()); err != nil {
|
dbtest.InitDB(t, config.GetDBPath())
|
||||||
t.Fatalf("init db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tokenNames(t *testing.T) []string {
|
func tokenNames(t *testing.T) []string {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { llms } from 'fumadocs-core/source';
|
|||||||
|
|
||||||
export const revalidate = false;
|
export const revalidate = false;
|
||||||
|
|
||||||
export function GET() {
|
export async function GET() {
|
||||||
return new Response(llms(source).index());
|
return new Response(await llms(source).index());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
|
|||||||
├── install.sh / update.sh / x-ui.sh # VPS install + management CLI
|
├── install.sh / update.sh / x-ui.sh # VPS install + management CLI
|
||||||
├── x-ui.service.* / x-ui.rc # systemd units (debian/rhel/arch) + rc script
|
├── x-ui.service.* / x-ui.rc # systemd units (debian/rhel/arch) + rc script
|
||||||
├── windows_files/ # Windows service support
|
├── windows_files/ # Windows service support
|
||||||
└── .github/workflows/ # CI: ci.yml, codeql.yml, docker.yml, release.yml, smoke.yml,
|
└── .github/workflows/ # CI: ci.yml, codeql.yml, docker.yml, release.yml,
|
||||||
# mutation.yml, cleanup_caches.yml, claude-pr-review.yml,
|
# mutation.yml, cleanup_caches.yml, claude-pr-review.yml,
|
||||||
# claude-issue-analyst.yml
|
# claude-issue-analyst.yml
|
||||||
```
|
```
|
||||||
@@ -565,7 +565,7 @@ golangci-lint run # full lint (gofumpt + goimports formatting)
|
|||||||
go run main.go # run the panel locally (serves embedded dist if built)
|
go run main.go # run the panel locally (serves embedded dist if built)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend (`cd frontend`, Node 24 — see `.nvmrc`):**
|
**Frontend (`cd frontend`, Node 26 — see `.nvmrc`):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -583,7 +583,7 @@ root → `go build ./...` / `go run main.go`.
|
|||||||
**Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`).
|
**Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`).
|
||||||
|
|
||||||
**CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan),
|
**CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan),
|
||||||
`smoke.yml` (smoke tests), `mutation.yml` (mutation testing), `docker.yml` + `release.yml`
|
`mutation.yml` (mutation testing), `docker.yml` + `release.yml`
|
||||||
(multi-arch image + release builds), `cleanup_caches.yml`, `claude-pr-review.yml` (PR review
|
(multi-arch image + release builds), `cleanup_caches.yml`, `claude-pr-review.yml` (PR review
|
||||||
only - it changes no code), `claude-issue-analyst.yml` (issue triage).
|
only - it changes no code), `claude-issue-analyst.yml` (issue triage).
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ value defeats the point, since DPI can fingerprint it over time.
|
|||||||
| ------------ | ---------------------------------------------------------------------------- |
|
| ------------ | ---------------------------------------------------------------------------- |
|
||||||
| **Jc** | Number of junk packets sent before the handshake. |
|
| **Jc** | Number of junk packets sent before the handshake. |
|
||||||
| **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`. |
|
| **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`. |
|
||||||
| **S1/S2** | Padding added to the handshake init/response packets. `S1 + 56` must not equal `S2` — amneziawg-go rejects a value that would make both packets the same size. |
|
| **S1/S2** | Padding added to the handshake init/response packets, `0`-`1552` / `0`-`1608`: the packets are `148 + S1` and `92 + S2` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. The panel also rejects `S1 + 56 = S2`, which would give both packets the same size on the wire (amneziawg-go itself accepts it). An AmneziaWG outbound takes the remote server's values as they are, up to `65535`. |
|
||||||
| **S3** | Cookie-reply padding, `0`-`64`. |
|
| **S3** | Cookie-reply padding, `0`-`1636`: the reply is `64 + S3` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. An outbound, as with S1/S2, takes the remote server's value up to `65535`. |
|
||||||
| **S4** | Transport (data) packet padding, `0`-`32`. |
|
| **S4** | Transport (data) packet padding, `0`-`32`. |
|
||||||
| **H1-H4** | Magic header values that replace WireGuard's standard message-type bytes. Each is a single integer or a `low-high` range; `1`-`4` are reserved (real WireGuard message types) and must not be used. |
|
| **H1-H4** | Header values that replace WireGuard's message-type field. Each is a single integer or a `low-high` range, and the four must not overlap — amneziawg-go and the kernel module refuse the whole device otherwise. `1`-`4` are WireGuard's own types and the engine default for a blank field: valid, but without a HeaderProtectionKey the type field then reads like plain WireGuard. |
|
||||||
| **I1-I5** | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. |
|
| **I1-I5** | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. |
|
||||||
| **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. |
|
| **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. |
|
||||||
| **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. |
|
| **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. |
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ inbounds** at once, with per-client traffic accounting.
|
|||||||
| **Limit IP** | all (except TUIC) | Max simultaneous source IPs (enforced via Fail2ban). |
|
| **Limit IP** | all (except TUIC) | Max simultaneous source IPs (enforced via Fail2ban). |
|
||||||
| **Total (GB)** | all (except TUIC) | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). |
|
| **Total (GB)** | all (except TUIC) | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). |
|
||||||
| **Expiry** | all | Date after which the client stops working. |
|
| **Expiry** | all | Date after which the client stops working. |
|
||||||
| **Reset** | all | Auto-renew period in **days** (rolls the quota over). |
|
| **Auto renewal** | all | Disabled, fixed interval in days, calendar weekly, or calendar monthly. |
|
||||||
| **Telegram ID**| all | Links the client to a Telegram user for self-service/notifications.|
|
| **Telegram ID**| all | Links the client to a Telegram user for self-service/notifications.|
|
||||||
| **Sub ID** | all | Subscription identifier grouping this client's links. |
|
| **Sub ID** | all | Subscription identifier grouping this client's links. |
|
||||||
| **Group** | all | Optional client group for organization and bulk filtering. |
|
| **Group** | all | Optional client group for organization and bulk filtering. |
|
||||||
| **Comment** | all | Free-text note. |
|
| **Comment** | all | Free-text note. |
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
Reaching the **traffic** or **expiry** limit disables the client; the panel can
|
Reaching the **traffic** or **expiry** limit disables the client, and a client
|
||||||
restart Xray automatically when clients are auto-disabled
|
disabled or deleted by hand counts too; the panel restarts Xray then
|
||||||
(`restartXrayOnClientDisable`, on by default).
|
(`restartXrayOnClientDisable`, on by default).
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
@@ -42,6 +42,68 @@ inbounds** at once, with per-client traffic accounting.
|
|||||||
- **Online status** and **last-online** times are tracked per client (and per
|
- **Online status** and **last-online** times are tracked per client (and per
|
||||||
node in multi-node setups).
|
node in multi-node setups).
|
||||||
|
|
||||||
|
## Automatic renewal
|
||||||
|
|
||||||
|
The individual and bulk-create forms offer one renewal mode at a time:
|
||||||
|
|
||||||
|
| Mode | API fields | Schedule |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Disabled | `reset=0`, `resetDay=0`, `resetWeekday=0` | The expiry is not renewed. |
|
||||||
|
| Fixed interval | `reset=N`, other two fields `0` | Add exactly N × 24 hours to the previous cutoff. |
|
||||||
|
| Calendar weekly | `resetWeekday=1..7`, other two fields `0` | Renew at panel-local midnight on Monday (1) through Sunday (7). |
|
||||||
|
| Calendar monthly | `resetDay=1..31`, `resetWeekday=0` | Renew at panel-local midnight on that day; missing dates clamp to the month's last day without losing the configured day. |
|
||||||
|
|
||||||
|
Calendar weeks stay on the selected weekday across daylight-saving changes;
|
||||||
|
they are not equivalent to a fixed seven-day interval. A skipped midnight uses
|
||||||
|
the first valid instant of that date; a repeated midnight uses the first one.
|
||||||
|
If a timezone skips the entire selected date, the next matching week is used.
|
||||||
|
Existing monthly clients
|
||||||
|
that also have `reset` set retain monthly precedence. The API rejects weekly
|
||||||
|
renewal combined with a positive `reset` or `resetDay`.
|
||||||
|
|
||||||
|
For a full calendar month, select **monthly, day 1** and set the initial cutoff
|
||||||
|
to the next month's first midnight. For example, `2030-09-01 00:00:00` is valid
|
||||||
|
through `2030-08-31 23:59:59`. Day 31 renews at the **start** of the 31st and is
|
||||||
|
not the same schedule. The existing optional month-end subscription-header
|
||||||
|
display remains a separate setting and is not enabled by this form.
|
||||||
|
|
||||||
|
The preview uses the panel's timezone and the same calendar/catch-up calculation
|
||||||
|
as automatic renewal. It shows the cutoff, last valid second, next expiry, and
|
||||||
|
allowances needed. It is informational: it does not save, activate, reserve, or
|
||||||
|
guarantee a future renewal. When no expiry is set, auto-renewal cannot run; an
|
||||||
|
explicit button can set the first calendar cutoff. Selecting a mode alone never
|
||||||
|
rewrites an existing expiry. First-use clients keep their initial duration, and
|
||||||
|
their calendar dates are available after activation.
|
||||||
|
|
||||||
|
For legacy last-second calendar cutoffs, the renewal boundary includes the
|
||||||
|
existing free alignment to the following midnight. The last-valid-second
|
||||||
|
preview still uses the **stored expiry**, not that alignment: an exclusive
|
||||||
|
`23:59:59` cutoff is valid through `23:59:58`. Use a next-midnight cutoff for
|
||||||
|
full-day validity; the preview itself does not repair the initial expiry.
|
||||||
|
|
||||||
|
`resetMax=0` means unlimited renewals. A positive limit counts **each elapsed
|
||||||
|
period**, including offline catch-up, not each scheduler tick or attached inbound.
|
||||||
|
If the remaining allowances cannot reach a future cutoff, the client stays
|
||||||
|
expired and its traffic is not reset. Operator-disabled clients stay disabled.
|
||||||
|
|
||||||
|
Renewal already resets client traffic. The separate **periodic traffic reset**
|
||||||
|
does not move the expiry and is unchanged; keep it disabled unless you intend an
|
||||||
|
additional reset. Quarterly, yearly, and every-N-week/month schedules are not
|
||||||
|
part of these modes.
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
Upgrade the main panel and every participating node before enabling weekly
|
||||||
|
renewal. Older versions ignore `resetWeekday`; a weekly-only client would not
|
||||||
|
auto-renew and, after its expiry or quota is exhausted, can be deleted by
|
||||||
|
**delete depleted clients** because older versions lack the weekly protection.
|
||||||
|
Back up the database and convert weekly schedules to a renewal mode supported
|
||||||
|
by every participating version before downgrading. Merely disabling weekly
|
||||||
|
renewal does not protect a depleted client from deletion. Avoid depleted-client
|
||||||
|
cleanup while a mixed-version fleet or unconverted weekly clients remain.
|
||||||
|
Database upgrades default this new field to `0` and preserve existing limits
|
||||||
|
and dates.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
## Share links and external links
|
## Share links and external links
|
||||||
|
|
||||||
Every client has share links and a QR code for its inbounds, plus a combined
|
Every client has share links and a QR code for its inbounds, plus a combined
|
||||||
|
|||||||
@@ -94,6 +94,25 @@ Subscriptions return standard headers that compatible apps read:
|
|||||||
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
||||||
**`Announce`** — optional branding shown by some clients.
|
**`Announce`** — optional branding shown by some clients.
|
||||||
|
|
||||||
|
### Profile page links and upgrades
|
||||||
|
|
||||||
|
In **Subscription → Profile → Profile page**, choose `subProfileMode` for all
|
||||||
|
subscription clients:
|
||||||
|
|
||||||
|
- **No link** (`none`, default): omit `Profile-Web-Page-Url`.
|
||||||
|
- **Built-in subscription page** (`builtin`): link to the client's built-in page.
|
||||||
|
- **Custom website** (`custom`): use `subProfileUrl`; a blank URL omits the header.
|
||||||
|
|
||||||
|
**Upgrade note:** previously, an empty `subProfileUrl` automatically linked to
|
||||||
|
the built-in page. After upgrading, an unset mode with an empty or whitespace-only
|
||||||
|
URL becomes **No link**; an existing nonempty URL remains a **Custom website**.
|
||||||
|
To restore the built-in link, select **Built-in subscription page** above and
|
||||||
|
save the settings.
|
||||||
|
|
||||||
|
The built-in page exposes subscription URLs and node configurations, including
|
||||||
|
for Happ encrypted subscriptions. Enable it only if you intend to provide that
|
||||||
|
access.
|
||||||
|
|
||||||
### Optional month-end expiry display
|
### Optional month-end expiry display
|
||||||
|
|
||||||
Under **Subscription → Information**, **Month-end subscription expiry display**
|
Under **Subscription → Information**, **Month-end subscription expiry display**
|
||||||
|
|||||||
@@ -53,13 +53,22 @@ Additional commands:
|
|||||||
|
|
||||||
| Command | Who | Action |
|
| Command | Who | Action |
|
||||||
| ------------------ | ------ | ------------------------------------------------------------ |
|
| ------------------ | ------ | ------------------------------------------------------------ |
|
||||||
| `/start`, `/help` | anyone | Greeting and the menu of inline buttons |
|
| `/start` | anyone | Greeting and the menu of inline buttons; an unlinked account gets only its Telegram ID |
|
||||||
| `/status` | anyone | Confirm the bot is alive |
|
| `/help` | both | The menu of inline buttons |
|
||||||
|
| `/status` | both | Confirm the bot is alive |
|
||||||
| `/id` | anyone | Show your Telegram numeric ID |
|
| `/id` | anyone | Show your Telegram numeric ID |
|
||||||
| `/usage <arg>` | both | Admins search clients; users look up their own usage |
|
| `/usage <arg>` | both | Admins search clients; users look up their own usage |
|
||||||
| `/inbound <remark>`| admin | Show an inbound's details |
|
| `/inbound <remark>`| admin | Show an inbound's details |
|
||||||
| `/restart` | admin | Restart Xray |
|
| `/restart` | admin | Restart Xray |
|
||||||
|
|
||||||
|
A user is a Telegram account linked to at least one client. Any other account
|
||||||
|
can run only `/start` and `/id`; the bot ignores its other commands and
|
||||||
|
button taps. To link a customer, tap **Invite Link** on the client's card in the bot and
|
||||||
|
send them the `t.me` link: the first account to open it is linked to every
|
||||||
|
client that shares that Subscription ID. The Subscription ID is the invite code,
|
||||||
|
so keep it long and random. Each account gets five claim attempts an hour, and
|
||||||
|
admins are notified when one runs out.
|
||||||
|
|
||||||
Admins also get inline-button flows for server usage, sorted traffic reports,
|
Admins also get inline-button flows for server usage, sorted traffic reports,
|
||||||
resetting traffic, DB backups, ban logs, listing inbounds/clients, online
|
resetting traffic, DB backups, ban logs, listing inbounds/clients, online
|
||||||
clients, "depleting soon", and a full **add-client** wizard. Regular users get
|
clients, "depleting soon", and a full **add-client** wizard. Regular users get
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ _openapi:
|
|||||||
this — the middleware short-circuits CSRF for authenticated API
|
this — the middleware short-circuits CSRF for authenticated API
|
||||||
requests.
|
requests.
|
||||||
url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests'
|
url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests'
|
||||||
|
- depth: 2
|
||||||
|
title: Public. Active paid sponsor placements read from the project
|
||||||
|
sponsors.json (cached for 1h); entries outside their from/until window
|
||||||
|
are dropped. Logos are proxied by the panel at /sponsors/logo/{name}.
|
||||||
|
Used by the login page and panel sponsor slots.
|
||||||
|
url: '#public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-entries-outside-their-fromuntil-window-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Returns whether 2FA is enabled on the panel — used by the login page to
|
title: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||||
decide whether to show the OTP field.
|
decide whether to show the OTP field.
|
||||||
@@ -39,6 +45,11 @@ _openapi:
|
|||||||
this — the middleware short-circuits CSRF for authenticated API
|
this — the middleware short-circuits CSRF for authenticated API
|
||||||
requests.
|
requests.
|
||||||
id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests
|
id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests
|
||||||
|
- content: Public. Active paid sponsor placements read from the project
|
||||||
|
sponsors.json (cached for 1h); entries outside their from/until window
|
||||||
|
are dropped. Logos are proxied by the panel at /sponsors/logo/{name}.
|
||||||
|
Used by the login page and panel sponsor slots.
|
||||||
|
id: public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-entries-outside-their-fromuntil-window-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots
|
||||||
- content: Returns whether 2FA is enabled on the panel — used by the login page to
|
- content: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||||
decide whether to show the OTP field.
|
decide whether to show the OTP field.
|
||||||
id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
|
id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
|
||||||
@@ -54,7 +65,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,9 @@ _openapi:
|
|||||||
call. Body is JSON. Per-protocol secrets are generated server-side when
|
call. Body is JSON. Per-protocol secrets are generated server-side when
|
||||||
omitted, so callers can send only the universal fields.
|
omitted, so callers can send only the universal fields.
|
||||||
url: '#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields'
|
url: '#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields'
|
||||||
|
- depth: 2
|
||||||
|
title: Preview client auto-renewal dates without saving or resetting anything.
|
||||||
|
url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Update an existing client by email. Changes propagate to every attached
|
title: Update an existing client by email. Changes propagate to every attached
|
||||||
inbound. Body is the JSON client payload — supply the full set of fields
|
inbound. Body is the JSON client payload — supply the full set of fields
|
||||||
@@ -78,21 +81,27 @@ _openapi:
|
|||||||
Returns the deleted count. Cannot be undone.
|
Returns the deleted count. Cannot be undone.
|
||||||
url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone'
|
url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Return every client as a {client, inboundIds} array — the same shape
|
title: Return every client as a {client, inboundIds, traffic} array — the shape
|
||||||
/bulkCreate and /import accept — so the payload round-trips straight
|
/import accepts — so the payload round-trips straight back through
|
||||||
back through /import. Clients with no inbound attachment are included
|
/import. traffic carries the usage counters (up, down, resetCount,
|
||||||
with an empty inboundIds list. The UI shows this in a CodeMirror viewer
|
lastOnline, lastSubFetch) and is omitted for a client with no traffic
|
||||||
(copy / download); programmatic callers get the array in obj.
|
row; the quota itself stays in client.totalGB. Clients with no inbound
|
||||||
url: '#return-every-client-as-a-client-inboundids-array--the-same-shape-bulkcreate-and-import-accept--so-the-payload-round-trips-straight-back-through-import-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj'
|
attachment are included with an empty inboundIds list. The UI shows this
|
||||||
|
in a CodeMirror viewer (copy / download); programmatic callers get the
|
||||||
|
array in obj.
|
||||||
|
url: '#return-every-client-as-a-client-inboundids-traffic-array--the-shape-import-accepts--so-the-payload-round-trips-straight-back-through-import-traffic-carries-the-usage-counters-up-down-resetcount-lastonline-lastsubfetch-and-is-omitted-for-a-client-with-no-traffic-row-the-quota-itself-stays-in-clienttotalgb-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: 'Import clients from a JSON body { "data": "<json>" }, where data is a
|
title: 'Import clients from a JSON body { "data": "<json>" }, where data is a
|
||||||
string-encoded array produced by /export ([{client, inboundIds}]). Items
|
string-encoded array produced by /export ([{client, inboundIds,
|
||||||
with inboundIds are created and attached to those inbounds; items with
|
traffic}]). Items with inboundIds are created and attached to those
|
||||||
an empty inboundIds list are restored as unattached client records.
|
inbounds; items with an empty inboundIds list are restored as unattached
|
||||||
Existing emails are never overwritten — they are returned in skipped.
|
client records. An optional traffic object restores the usage counters,
|
||||||
Triggers a single Xray restart at the end if any target inbound was
|
only for clients this import creates. Existing emails are never
|
||||||
running.'
|
overwritten — they are returned in skipped, and their live counters are
|
||||||
url: '#import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-existing-emails-are-never-overwritten--they-are-returned-in-skipped-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running'
|
left untouched. Triggers a single Xray restart at the end if any target
|
||||||
|
inbound was running; a failure while restoring counters still reports
|
||||||
|
success=false after the clients were created.'
|
||||||
|
url: '#import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-traffic-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-an-optional-traffic-object-restores-the-usage-counters-only-for-clients-this-import-creates-existing-emails-are-never-overwritten--they-are-returned-in-skipped-and-their-live-counters-are-left-untouched-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running-a-failure-while-restoring-counters-still-reports-successfalse-after-the-clients-were-created'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: 'Shift expiry and/or traffic quota for many clients in one call.
|
title: 'Shift expiry and/or traffic quota for many clients in one call.
|
||||||
addDays/addBytes may be negative. Clients with unlimited expiry
|
addDays/addBytes may be negative. Clients with unlimited expiry
|
||||||
@@ -317,6 +326,8 @@ _openapi:
|
|||||||
call. Body is JSON. Per-protocol secrets are generated server-side
|
call. Body is JSON. Per-protocol secrets are generated server-side
|
||||||
when omitted, so callers can send only the universal fields.
|
when omitted, so callers can send only the universal fields.
|
||||||
id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||||
|
- content: Preview client auto-renewal dates without saving or resetting anything.
|
||||||
|
id: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
|
||||||
- content: Update an existing client by email. Changes propagate to every attached
|
- content: Update an existing client by email. Changes propagate to every attached
|
||||||
inbound. Body is the JSON client payload — supply the full set of
|
inbound. Body is the JSON client payload — supply the full set of
|
||||||
fields you want to keep (the server replaces the row, it does not
|
fields you want to keep (the server replaces the row, it does not
|
||||||
@@ -351,20 +362,26 @@ _openapi:
|
|||||||
clearing clients left unattached after their inbounds were removed.
|
clearing clients left unattached after their inbounds were removed.
|
||||||
Returns the deleted count. Cannot be undone.
|
Returns the deleted count. Cannot be undone.
|
||||||
id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone
|
id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone
|
||||||
- content: Return every client as a {client, inboundIds} array — the same shape
|
- content: Return every client as a {client, inboundIds, traffic} array — the
|
||||||
/bulkCreate and /import accept — so the payload round-trips straight
|
shape /import accepts — so the payload round-trips straight back
|
||||||
back through /import. Clients with no inbound attachment are included
|
through /import. traffic carries the usage counters (up, down,
|
||||||
with an empty inboundIds list. The UI shows this in a CodeMirror
|
resetCount, lastOnline, lastSubFetch) and is omitted for a client with
|
||||||
viewer (copy / download); programmatic callers get the array in obj.
|
no traffic row; the quota itself stays in client.totalGB. Clients with
|
||||||
id: return-every-client-as-a-client-inboundids-array--the-same-shape-bulkcreate-and-import-accept--so-the-payload-round-trips-straight-back-through-import-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj
|
no inbound attachment are included with an empty inboundIds list. The
|
||||||
|
UI shows this in a CodeMirror viewer (copy / download); programmatic
|
||||||
|
callers get the array in obj.
|
||||||
|
id: return-every-client-as-a-client-inboundids-traffic-array--the-shape-import-accepts--so-the-payload-round-trips-straight-back-through-import-traffic-carries-the-usage-counters-up-down-resetcount-lastonline-lastsubfetch-and-is-omitted-for-a-client-with-no-traffic-row-the-quota-itself-stays-in-clienttotalgb-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj
|
||||||
- content: 'Import clients from a JSON body { "data": "<json>" }, where data is a
|
- content: 'Import clients from a JSON body { "data": "<json>" }, where data is a
|
||||||
string-encoded array produced by /export ([{client, inboundIds}]).
|
string-encoded array produced by /export ([{client, inboundIds,
|
||||||
Items with inboundIds are created and attached to those inbounds;
|
traffic}]). Items with inboundIds are created and attached to those
|
||||||
items with an empty inboundIds list are restored as unattached client
|
inbounds; items with an empty inboundIds list are restored as
|
||||||
records. Existing emails are never overwritten — they are returned in
|
unattached client records. An optional traffic object restores the
|
||||||
skipped. Triggers a single Xray restart at the end if any target
|
usage counters, only for clients this import creates. Existing emails
|
||||||
inbound was running.'
|
are never overwritten — they are returned in skipped, and their live
|
||||||
id: import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-existing-emails-are-never-overwritten--they-are-returned-in-skipped-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running
|
counters are left untouched. Triggers a single Xray restart at the end
|
||||||
|
if any target inbound was running; a failure while restoring counters
|
||||||
|
still reports success=false after the clients were created.'
|
||||||
|
id: import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-traffic-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-an-optional-traffic-object-restores-the-usage-counters-only-for-clients-this-import-creates-existing-emails-are-never-overwritten--they-are-returned-in-skipped-and-their-live-counters-are-left-untouched-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running-a-failure-while-restoring-counters-still-reports-successfalse-after-the-clients-were-created
|
||||||
- content: 'Shift expiry and/or traffic quota for many clients in one call.
|
- content: 'Shift expiry and/or traffic quota for many clients in one call.
|
||||||
addDays/addBytes may be negative. Clients with unlimited expiry
|
addDays/addBytes may be negative. Clients with unlimited expiry
|
||||||
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
|
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
|
||||||
@@ -592,6 +609,16 @@ _openapi:
|
|||||||
one per line. `limitHwid` is applied only when every inbound
|
one per line. `limitHwid` is applied only when every inbound
|
||||||
succeeded, so re-run the call after fixing the failure.
|
succeeded, so re-run the call after fixing the failure.
|
||||||
heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||||
|
- content: Uses the same calendar and catch-up calculation as auto-renew in the
|
||||||
|
panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables
|
||||||
|
weekly mode; it cannot be combined with positive reset or resetDay.
|
||||||
|
Existing resetDay takes precedence over reset. With expiryTime=0,
|
||||||
|
calendar modes suggest a first cutoff but do not activate renewal.
|
||||||
|
Negative expiryTime waits for first-use activation. resetMax and
|
||||||
|
resetCount simulate the existing per-period allowance limit; the
|
||||||
|
preview is informational and does not reserve an allowance or
|
||||||
|
guarantee node availability.
|
||||||
|
heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
|
||||||
- content: 'The inbounds are applied concurrently and independently: one that
|
- content: 'The inbounds are applied concurrently and independently: one that
|
||||||
fails no longer stops the others. Every inbound error names the
|
fails no longer stops the others. Every inbound error names the
|
||||||
inbound it came from (`inbound 7: <message>`), and several failures
|
inbound it came from (`inbound 7: <message>`), and several failures
|
||||||
@@ -638,7 +665,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ default. Encryption at rest is opt-in and fails closed: with any mode other than
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `NODE_TOKEN_ENCRYPTION` | `off` | `off`, `migration` (reads accept plaintext or ciphertext, writes encrypt), or `required` (same writes, startup fails without a key). Note the missing `XUI_` prefix. |
|
| `NODE_TOKEN_ENCRYPTION` | `off` | `off`, `migration` (reads accept plaintext or ciphertext, writes encrypt), or `required` (same writes, startup fails without a key). Note the missing `XUI_` prefix. |
|
||||||
| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON keyring, mode `0600` or stricter. Loaded first. |
|
| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON keyring, mode `0600` or stricter (not checked on Windows, where NTFS permissions protect it). Loaded first. |
|
||||||
| `XUI_NODE_TOKEN_KEY` | — | A single base64 32-byte key, read only when the key file fails to load. Its key id is fixed to `env`, so it cannot rotate. |
|
| `XUI_NODE_TOKEN_KEY` | — | A single base64 32-byte key, read only when the key file fails to load. Its key id is fixed to `env`, so it cannot rotate. |
|
||||||
|
|
||||||
The key file names the active key plus every older key still needed to decrypt:
|
The key file names the active key plus every older key still needed to decrypt:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "Reference",
|
"title": "Reference",
|
||||||
"icon": "BookMarked",
|
"icon": "BookBookmark",
|
||||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ icon: Users
|
|||||||
| **Comment** | همه | یادداشت متنی آزاد. |
|
| **Comment** | همه | یادداشت متنی آزاد. |
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ پنل میتواند
|
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ غیرفعالسازی یا
|
||||||
هنگام غیرفعالشدن خودکار کلاینتها، Xray را بهصورت خودکار راهاندازی مجدد کند
|
حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راهاندازی مجدد میکند
|
||||||
(`restartXrayOnClientDisable`، بهصورت پیشفرض فعال).
|
(`restartXrayOnClientDisable`، بهصورت پیشفرض فعال).
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,23 @@ SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) بهعلاوه
|
|||||||
- **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
|
- **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
|
||||||
**`Announce`** — برندینگ اختیاری که برخی کلاینتها نمایش میدهند.
|
**`Announce`** — برندینگ اختیاری که برخی کلاینتها نمایش میدهند.
|
||||||
|
|
||||||
|
### لینک صفحه پروفایل
|
||||||
|
|
||||||
|
در تنظیمات **سابسکریپشن ← پروفایل**، گزینه **صفحه پروفایل** (`subProfileMode`)
|
||||||
|
لینک را برای همه کلاینتهای اشتراک کنترل میکند:
|
||||||
|
|
||||||
|
- **بدون لینک** (`none`، پیشفرض) — هدر `Profile-Web-Page-Url` ارسال نمیشود.
|
||||||
|
- **صفحه اشتراک داخلی** (`builtin`) — لینک صفحه اشتراک داخلی ارائه میشود.
|
||||||
|
- **وبسایت سفارشی** (`custom`) — آدرس `subProfileUrl` استفاده میشود؛ اگر خالی باشد، هدر ارسال نمیشود.
|
||||||
|
|
||||||
|
**پس از ارتقا:** اگر `subProfileMode` هنوز تنظیم نشده و مقدار قبلی `subProfileUrl`
|
||||||
|
خالی یا فقط شامل فاصله باشد، بهجای لینک خودکار صفحه داخلی، حالت **بدون لینک**
|
||||||
|
انتخاب میشود. آدرس سفارشی غیرخالی قبلی در حالت **وبسایت سفارشی** حفظ میشود.
|
||||||
|
|
||||||
|
برای بازگرداندن لینک قبلی، در همین بخش **صفحه اشتراک داخلی** را انتخاب و تنظیمات
|
||||||
|
را ذخیره کنید. این صفحه آدرسهای اشتراک و پیکربندی گرهها را آشکار میکند، حتی
|
||||||
|
برای اشتراکهای رمزگذاریشده Happ.
|
||||||
|
|
||||||
## قالبهای سفارشی صفحه
|
## قالبهای سفارشی صفحه
|
||||||
|
|
||||||
برای برندینگ صفحهی HTML اشتراک، `subThemeDir` را به یک پوشهی حاوی قالب سفارشیِ
|
برای برندینگ صفحهی HTML اشتراک، `subThemeDir` را به یک پوشهی حاوی قالب سفارشیِ
|
||||||
|
|||||||
@@ -53,13 +53,22 @@ icon: Send
|
|||||||
|
|
||||||
| فرمان | چه کسی | عملکرد |
|
| فرمان | چه کسی | عملکرد |
|
||||||
| ------------------ | ------ | ------------------------------------------------------------ |
|
| ------------------ | ------ | ------------------------------------------------------------ |
|
||||||
| `/start`، `/help` | همه | پیام خوشآمدگویی و منوی دکمههای درونخطی |
|
| `/start` | همه | پیام خوشآمدگویی و منوی دکمههای درونخطی؛ حساب متصلنشده فقط شناسهی Telegram خود را میگیرد |
|
||||||
| `/status` | همه | تأیید فعال بودن ربات |
|
| `/help` | هر دو | منوی دکمههای درونخطی |
|
||||||
|
| `/status` | هر دو | تأیید فعال بودن ربات |
|
||||||
| `/id` | همه | نمایش شناسهی عددی Telegram شما |
|
| `/id` | همه | نمایش شناسهی عددی Telegram شما |
|
||||||
| `/usage <arg>` | هر دو | ادمینها کلاینتها را جستوجو میکنند؛ کاربران مصرف خود را میبینند |
|
| `/usage <arg>` | هر دو | ادمینها کلاینتها را جستوجو میکنند؛ کاربران مصرف خود را میبینند |
|
||||||
| `/inbound <remark>`| ادمین | نمایش جزئیات یک ورودی |
|
| `/inbound <remark>`| ادمین | نمایش جزئیات یک ورودی |
|
||||||
| `/restart` | ادمین | راهاندازی مجدد Xray |
|
| `/restart` | ادمین | راهاندازی مجدد Xray |
|
||||||
|
|
||||||
|
کاربر یعنی حساب Telegramی که دستکم به یک کلاینت متصل است. هر حساب دیگری فقط
|
||||||
|
`/start` و `/id` را میتواند اجرا کند و ربات فرمانها و دکمههای دیگر آن را نادیده
|
||||||
|
میگیرد. برای اتصال یک مشتری، در کارت کلاینت در ربات روی **لینک دعوت** بزنید و لینک `t.me`
|
||||||
|
را برایش بفرستید: نخستین حسابی که آن را باز کند به همهی کلاینتهایی که آن شناسه
|
||||||
|
اشتراک را دارند متصل میشود. شناسه اشتراک همان کد دعوت است، پس آن را طولانی و
|
||||||
|
تصادفی نگه دارید. هر حساب در هر ساعت پنج بار میتواند تلاش کند و پس از آن به
|
||||||
|
ادمینها اطلاع داده میشود.
|
||||||
|
|
||||||
ادمینها همچنین جریانهای دکمهی درونخطی برای مصرف سرور، گزارشهای ترافیک مرتبشده،
|
ادمینها همچنین جریانهای دکمهی درونخطی برای مصرف سرور، گزارشهای ترافیک مرتبشده،
|
||||||
بازنشانی ترافیک، پشتیبانگیری از DB، گزارشهای مسدودسازی، فهرست کردن ورودیها/کلاینتها،
|
بازنشانی ترافیک، پشتیبانگیری از DB، گزارشهای مسدودسازی، فهرست کردن ورودیها/کلاینتها،
|
||||||
کلاینتهای آنلاین، «بهزودی تمامشونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.
|
کلاینتهای آنلاین، «بهزودی تمامشونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ icon: Variable
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `NODE_TOKEN_ENCRYPTION` | `off` | `off`، `migration` (خواندن هم متن ساده و هم متن رمزشده را میپذیرد، نوشتن همیشه رمز میکند) یا `required` (نوشتن یکسان، اما بدون کلید اجرا شکست میخورد). به نبودِ پیشوند `XUI_` توجه کنید. |
|
| `NODE_TOKEN_ENCRYPTION` | `off` | `off`، `migration` (خواندن هم متن ساده و هم متن رمزشده را میپذیرد، نوشتن همیشه رمز میکند) یا `required` (نوشتن یکسان، اما بدون کلید اجرا شکست میخورد). به نبودِ پیشوند `XUI_` توجه کنید. |
|
||||||
| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | حلقهکلید JSON با دسترسی `0600` یا محدودتر. نخست همین بارگذاری میشود. |
|
| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | حلقهکلید JSON با دسترسی `0600` یا محدودتر (در ویندوز بررسی نمیشود و مجوزهای NTFS از آن محافظت میکنند). نخست همین بارگذاری میشود. |
|
||||||
| `XUI_NODE_TOKEN_KEY` | — | یک کلید ۳۲ بایتی base64 که فقط هنگام شکست بارگذاری فایل کلید خوانده میشود. شناسهی کلید آن ثابت و برابر `env` است، پس امکان چرخش ندارد. |
|
| `XUI_NODE_TOKEN_KEY` | — | یک کلید ۳۲ بایتی base64 که فقط هنگام شکست بارگذاری فایل کلید خوانده میشود. شناسهی کلید آن ثابت و برابر `env` است، پس امکان چرخش ندارد. |
|
||||||
|
|
||||||
فایل کلید، کلید فعال بههمراه هر کلید قدیمیای را که هنوز برای رمزگشایی لازم است نام میبرد:
|
فایل کلید، کلید فعال بههمراه هر کلید قدیمیای را که هنوز برای رمزگشایی لازم است نام میبرد:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "مرجع",
|
"title": "مرجع",
|
||||||
"icon": "BookMarked",
|
"icon": "BookBookmark",
|
||||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ icon: Users
|
|||||||
| **Comment** | все | Произвольная текстовая заметка. |
|
| **Comment** | все | Произвольная текстовая заметка. |
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
Достижение лимита **трафика** или **срока действия** отключает клиента; при
|
Достижение лимита **трафика** или **срока действия** отключает клиента, как и
|
||||||
автоматическом отключении клиентов панель может автоматически перезапускать
|
ручное отключение или удаление; тогда панель перезапускает Xray
|
||||||
Xray (`restartXrayOnClientDisable`, включено по умолчанию).
|
(`restartXrayOnClientDisable`, включено по умолчанию).
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Лимиты и контроль IP
|
## Лимиты и контроль IP
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ policy) плюс исходящее соединение `proxy`, указыва
|
|||||||
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
||||||
**`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
|
**`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
|
||||||
|
|
||||||
|
### Ссылка на страницу профиля
|
||||||
|
|
||||||
|
В настройках **Подписка → Профиль** поле **Страница профиля** (`subProfileMode`)
|
||||||
|
управляет ссылкой для всех клиентов подписки:
|
||||||
|
|
||||||
|
- **Без ссылки** (`none`, по умолчанию) — заголовок `Profile-Web-Page-Url` не отправляется.
|
||||||
|
- **Встроенная страница подписки** (`builtin`) — ссылка на встроенную страницу подписки.
|
||||||
|
- **Свой сайт** (`custom`) — адрес из `subProfileUrl`; если он пуст, заголовок не отправляется.
|
||||||
|
|
||||||
|
**После обновления:** если `subProfileMode` ещё не задан, а прежний `subProfileUrl`
|
||||||
|
пуст или содержит только пробелы, вместо автоматической ссылки на встроенную
|
||||||
|
страницу теперь используется **Без ссылки**. Существующий непустой пользовательский
|
||||||
|
адрес сохраняется в режиме **Свой сайт**.
|
||||||
|
|
||||||
|
Чтобы вернуть прежнюю ссылку, выберите **Встроенная страница подписки** в этом поле
|
||||||
|
и сохраните настройки. Эта страница раскрывает URL-адреса подписок и конфигурации
|
||||||
|
узлов, в том числе для зашифрованных подписок Happ.
|
||||||
|
|
||||||
## Пользовательские шаблоны страниц
|
## Пользовательские шаблоны страниц
|
||||||
|
|
||||||
Укажите в `subThemeDir` папку с пользовательским шаблоном информационной
|
Укажите в `subThemeDir` папку с пользовательским шаблоном информационной
|
||||||
|
|||||||
@@ -55,13 +55,23 @@ chat ID** (через запятую). Сохраните, затем напиш
|
|||||||
|
|
||||||
| Команда | Кому | Действие |
|
| Команда | Кому | Действие |
|
||||||
| ------------------ | ------ | ------------------------------------------------------------ |
|
| ------------------ | ------ | ------------------------------------------------------------ |
|
||||||
| `/start`, `/help` | всем | Приветствие и меню встроенных кнопок |
|
| `/start` | всем | Приветствие и меню встроенных кнопок; непривязанный аккаунт получает только свой Telegram ID |
|
||||||
| `/status` | всем | Подтверждает, что бот работает |
|
| `/help` | обоим | Меню встроенных кнопок |
|
||||||
|
| `/status` | обоим | Подтверждает, что бот работает |
|
||||||
| `/id` | всем | Показывает ваш числовой Telegram ID |
|
| `/id` | всем | Показывает ваш числовой Telegram ID |
|
||||||
| `/usage <arg>` | обоим | Администраторы ищут клиентов; пользователи смотрят свой расход |
|
| `/usage <arg>` | обоим | Администраторы ищут клиентов; пользователи смотрят свой расход |
|
||||||
| `/inbound <remark>`| админ | Показывает сведения о входящем подключении |
|
| `/inbound <remark>`| админ | Показывает сведения о входящем подключении |
|
||||||
| `/restart` | админ | Перезапускает Xray |
|
| `/restart` | админ | Перезапускает Xray |
|
||||||
|
|
||||||
|
Пользователь — это аккаунт Telegram, привязанный хотя бы к одному клиенту. Любой
|
||||||
|
другой аккаунт может выполнять только `/start` и `/id`; остальные его команды и
|
||||||
|
нажатия кнопок бот игнорирует. Чтобы привязать клиента, нажмите
|
||||||
|
**Ссылка-приглашение** в карточке клиента в боте и отправьте ему ссылку `t.me`: первый
|
||||||
|
открывший её аккаунт привязывается ко всем клиентам с этим ID подписки. ID
|
||||||
|
подписки служит кодом приглашения, поэтому делайте его длинным и случайным.
|
||||||
|
У каждого аккаунта пять попыток в час, после чего администраторы получают
|
||||||
|
уведомление.
|
||||||
|
|
||||||
Администраторам также доступны сценарии со встроенными кнопками: использование
|
Администраторам также доступны сценарии со встроенными кнопками: использование
|
||||||
сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
|
сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
|
||||||
журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,
|
журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ API-токены узлов — и сохранённый токен PIA — п
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `NODE_TOKEN_ENCRYPTION` | `off` | `off`, `migration` (чтение принимает открытый текст или шифротекст, запись всегда шифрует) или `required` (запись та же, но без ключа запуск не удастся). Префикса `XUI_` здесь нет. |
|
| `NODE_TOKEN_ENCRYPTION` | `off` | `off`, `migration` (чтение принимает открытый текст или шифротекст, запись всегда шифрует) или `required` (запись та же, но без ключа запуск не удастся). Префикса `XUI_` здесь нет. |
|
||||||
| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON-связка ключей с правами `0600` или строже. Загружается первой. |
|
| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON-связка ключей с правами `0600` или строже (в Windows не проверяется: файл защищают права NTFS). Загружается первой. |
|
||||||
| `XUI_NODE_TOKEN_KEY` | — | Один 32-байтный ключ в base64, читается только при неудачной загрузке файла ключей. Его идентификатор фиксирован (`env`), поэтому ротация невозможна. |
|
| `XUI_NODE_TOKEN_KEY` | — | Один 32-байтный ключ в base64, читается только при неудачной загрузке файла ключей. Его идентификатор фиксирован (`env`), поэтому ротация невозможна. |
|
||||||
|
|
||||||
Файл ключей задаёт активный ключ и все прежние ключи, ещё нужные для расшифровки:
|
Файл ключей задаёт активный ключ и все прежние ключи, ещё нужные для расшифровки:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "Справочник",
|
"title": "Справочник",
|
||||||
"icon": "BookMarked",
|
"icon": "BookBookmark",
|
||||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ icon: Users
|
|||||||
| **Limit IP** | 全部(TUIC 除外) | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。 |
|
| **Limit IP** | 全部(TUIC 除外) | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。 |
|
||||||
| **Total (GB)** | 全部(TUIC 除外) | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
|
| **Total (GB)** | 全部(TUIC 除外) | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
|
||||||
| **Expiry** | 全部 | 该日期之后客户端停止工作。 |
|
| **Expiry** | 全部 | 该日期之后客户端停止工作。 |
|
||||||
| **Reset** | 全部 | 以**天**为单位的自动续期周期(滚动重置配额)。 |
|
| **自动续期** | 全部 | 关闭、固定天数、日历每周或日历每月。 |
|
||||||
| **Telegram ID**| 全部 | 将客户端关联到 Telegram 用户,用于自助服务/通知。 |
|
| **Telegram ID**| 全部 | 将客户端关联到 Telegram 用户,用于自助服务/通知。 |
|
||||||
| **Sub ID** | 全部 | 用于对该客户端链接分组的订阅标识符。 |
|
| **Sub ID** | 全部 | 用于对该客户端链接分组的订阅标识符。 |
|
||||||
| **Group** | 全部 | 可选的客户端分组,便于组织管理和批量筛选。 |
|
| **Group** | 全部 | 可选的客户端分组,便于组织管理和批量筛选。 |
|
||||||
| **Comment** | 全部 | 自由文本备注。 |
|
| **Comment** | 全部 | 自由文本备注。 |
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以
|
达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
|
||||||
自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。
|
此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## 限制与 IP 控制
|
## 限制与 IP 控制
|
||||||
@@ -39,6 +39,58 @@ icon: Users
|
|||||||
并从该客户端的操作中清除它们。
|
并从该客户端的操作中清除它们。
|
||||||
- 系统会按客户端(在多节点部署中还会按节点)跟踪**在线状态**和**最后在线**时间。
|
- 系统会按客户端(在多节点部署中还会按节点)跟踪**在线状态**和**最后在线**时间。
|
||||||
|
|
||||||
|
## 自动续期
|
||||||
|
|
||||||
|
单个客户端和批量创建表单使用统一的续期模式选择:
|
||||||
|
|
||||||
|
| 模式 | API 字段 | 续期规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 关闭 | `reset=0`、`resetDay=0`、`resetWeekday=0` | 不自动延长到期时间。 |
|
||||||
|
| 固定天数 | `reset=N`,另两个字段为 `0` | 从上次截止时间增加 N × 24 小时。 |
|
||||||
|
| 日历每周 | `resetWeekday=1..7`,另两个字段为 `0` | 在面板时区每周一(1)至周日(7)的零点续期。 |
|
||||||
|
| 日历每月 | `resetDay=1..31`、`resetWeekday=0` | 在面板时区指定日的零点续期;短月取月末,之后仍按原配置日续期。 |
|
||||||
|
|
||||||
|
日历每周跨夏令时仍保持指定星期,不等于固定 7 天。若零点不存在,使用
|
||||||
|
该日期第一个有效时刻;零点重复时取第一次。若时区跳过整天,则使用
|
||||||
|
下一周的同一星期。旧配置同时填写
|
||||||
|
`reset` 和 `resetDay` 时继续以每月续期为准。API 不允许每周续期与正数
|
||||||
|
`reset` 或 `resetDay` 同时启用。
|
||||||
|
|
||||||
|
整自然月应选**每月、1 日**,首次截止时间设置为下月 1 日零点。例如
|
||||||
|
`2030-09-01 00:00:00` 表示有效至 `2030-08-31 23:59:59`。
|
||||||
|
31 日表示在 31 日**开始时**续期,并不是同一边界。订阅头原有的可选
|
||||||
|
月末显示设置仍独立存在,本表单不会自动开启它。
|
||||||
|
|
||||||
|
日期预览使用面板时区和后端实际续期的同一套计算,显示截止时间、最后
|
||||||
|
有效秒、下次到期时间及需要消耗的续期次数。预览不会保存、激活或预留
|
||||||
|
续期,也不保证未来一定续期。未设到期时间时自动续期无法运行,可以
|
||||||
|
明确点击按钮设置首次日历截止时间;仅选择模式不会修改已有到期时间。
|
||||||
|
“首次使用后开始”保留原来的初始天数,激活后才能确定日历日期。
|
||||||
|
|
||||||
|
旧配置以最后一秒为日历截止时间时,续期边界包含原有的不计次数向下个
|
||||||
|
零点对齐规则。但最后有效秒仍按**已存储的到期时间**计算,不会假装
|
||||||
|
初始时间已被修改:排他截止时间 `23:59:59` 实际有效至 `23:59:58`。
|
||||||
|
整天有效应使用下一个零点,预览本身不会修复首次截止时间。
|
||||||
|
|
||||||
|
最大续期次数 `resetMax=0` 表示不限次数。正数上限按**每个经过的周期**
|
||||||
|
计数,包括离线补续,不按定时任务执行次数或关联入站数量计数。剩余
|
||||||
|
次数不足以续到未来时,客户端继续过期且不会重置流量;手动禁用的
|
||||||
|
客户端保持禁用。
|
||||||
|
|
||||||
|
自动续期本身会重置客户端流量。独立的**定期流量重置**不延长到期时间,
|
||||||
|
这次未改变其规则;除非需要额外重置,否则保持关闭。本次不包含季度、
|
||||||
|
年度和每 N 周/月的续期。
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
启用每周续期前,需要升级主面板及所有参与节点。旧版本会忽略
|
||||||
|
`resetWeekday`,仅配置每周的客户端将无法自动续期,且在到期或流量
|
||||||
|
耗尽后,可能被**删除已耗尽客户端**操作删除,因为旧版没有每周续期
|
||||||
|
的清理保护。降级前应备份数据库,并将每周配置转换为所有参与版本
|
||||||
|
都支持的续期模式;仅关闭每周续期并不能防止耗尽后的删除。存在
|
||||||
|
混合版本或尚未转换的每周客户端时,应避免执行耗尽客户端清理。
|
||||||
|
数据库升级默认将新字段设为 `0`,保留已有日期和限制。
|
||||||
|
</Callout>
|
||||||
|
|
||||||
## 分享链接与外部链接
|
## 分享链接与外部链接
|
||||||
|
|
||||||
每个客户端都有针对其各入站的分享链接和二维码,外加一个合并的
|
每个客户端都有针对其各入站的分享链接和二维码,外加一个合并的
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,
|
|||||||
- **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
|
- **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
|
||||||
- **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
|
- **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
|
||||||
|
|
||||||
|
### 资料页链接与升级说明
|
||||||
|
|
||||||
|
在 **订阅 → 资料 → 资料页方式** 中选择 `subProfileMode`,对所有订阅客户端生效:
|
||||||
|
|
||||||
|
- **不提供**(`none`,默认):不发送 `Profile-Web-Page-Url`。
|
||||||
|
- **内置订阅页**(`builtin`):提供该客户端的内置订阅页链接。
|
||||||
|
- **自定义网站**(`custom`):使用 `subProfileUrl`;地址留空时不发送该响应头。
|
||||||
|
|
||||||
|
**升级提示:** 旧版在 `subProfileUrl` 留空时会自动提供内置订阅页链接。升级后,尚未设置模式且地址为空或仅含空白字符的配置会使用 **不提供**;已有非空地址继续使用 **自定义网站**。需要恢复内置入口时,在上述位置选择 **内置订阅页** 并保存设置。
|
||||||
|
|
||||||
|
内置订阅页会公开订阅地址和节点配置,Happ 加密订阅也不例外;请在确定需要提供这些内容时开启。
|
||||||
|
|
||||||
## 自定义页面模板
|
## 自定义页面模板
|
||||||
|
|
||||||
将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。
|
将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。
|
||||||
|
|||||||
@@ -51,13 +51,20 @@ icon: Send
|
|||||||
|
|
||||||
| 命令 | 适用对象 | 作用 |
|
| 命令 | 适用对象 | 作用 |
|
||||||
| ------------------ | ------ | ------------------------------------------------------------ |
|
| ------------------ | ------ | ------------------------------------------------------------ |
|
||||||
| `/start`、`/help` | 任何人 | 问候语以及内联按钮菜单 |
|
| `/start` | 任何人 | 问候语以及内联按钮菜单;未绑定的账号只会收到自己的 Telegram ID |
|
||||||
| `/status` | 任何人 | 确认机器人在线 |
|
| `/help` | 两者 | 内联按钮菜单 |
|
||||||
|
| `/status` | 两者 | 确认机器人在线 |
|
||||||
| `/id` | 任何人 | 显示你的 Telegram 数字 ID |
|
| `/id` | 任何人 | 显示你的 Telegram 数字 ID |
|
||||||
| `/usage <arg>` | 两者 | 管理员可搜索客户端;用户则查询自己的用量 |
|
| `/usage <arg>` | 两者 | 管理员可搜索客户端;用户则查询自己的用量 |
|
||||||
| `/inbound <remark>`| 管理员 | 显示某个入站的详情 |
|
| `/inbound <remark>`| 管理员 | 显示某个入站的详情 |
|
||||||
| `/restart` | 管理员 | 重启 Xray |
|
| `/restart` | 管理员 | 重启 Xray |
|
||||||
|
|
||||||
|
用户是指至少绑定了一个客户端的 Telegram 账号。其他账号只能使用 `/start` 和
|
||||||
|
`/id`,机器人会忽略它们的其他命令和按钮点击。要绑定客户,请在机器人的客户端卡片上点击
|
||||||
|
**邀请链接**,并把 `t.me` 链接发给对方:第一个打开该链接的账号会绑定到共用该订阅
|
||||||
|
ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长且随机。每个账号每小时
|
||||||
|
可尝试五次,用完后会通知管理员。
|
||||||
|
|
||||||
管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
|
管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
|
||||||
重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
|
重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
|
||||||
“即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看
|
“即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ icon: Variable
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `NODE_TOKEN_ENCRYPTION` | `off` | `off`、`migration`(读取时接受明文或密文,写入一律加密)或 `required`(写入相同,但缺少密钥时启动失败)。注意此处没有 `XUI_` 前缀。 |
|
| `NODE_TOKEN_ENCRYPTION` | `off` | `off`、`migration`(读取时接受明文或密文,写入一律加密)或 `required`(写入相同,但缺少密钥时启动失败)。注意此处没有 `XUI_` 前缀。 |
|
||||||
| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON 密钥环,权限须为 `0600` 或更严格。优先加载。 |
|
| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON 密钥环,权限须为 `0600` 或更严格(Windows 上不检查,由 NTFS 权限保护)。优先加载。 |
|
||||||
| `XUI_NODE_TOKEN_KEY` | — | 单个 base64 编码的 32 字节密钥,仅在密钥文件加载失败时读取。其密钥 ID 固定为 `env`,因此无法轮换。 |
|
| `XUI_NODE_TOKEN_KEY` | — | 单个 base64 编码的 32 字节密钥,仅在密钥文件加载失败时读取。其密钥 ID 固定为 `env`,因此无法轮换。 |
|
||||||
|
|
||||||
密钥文件同时记录活动密钥和所有仍需用于解密的旧密钥:
|
密钥文件同时记录活动密钥和所有仍需用于解密的旧密钥:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "参考",
|
"title": "参考",
|
||||||
"icon": "BookMarked",
|
"icon": "BookBookmark",
|
||||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-20
@@ -18,34 +18,34 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fumadocs-core": "^16.15.5",
|
"fumadocs-core": "^16.15.14",
|
||||||
"fumadocs-docgen": "^3.1.0",
|
"fumadocs-docgen": "^3.1.1",
|
||||||
"fumadocs-mdx": "^15.4.0",
|
"fumadocs-mdx": "^15.4.5",
|
||||||
"fumadocs-openapi": "^11.4.0",
|
"fumadocs-openapi": "^12.0.3",
|
||||||
"fumadocs-ui": "^16.15.5",
|
"fumadocs-ui": "^16.15.14",
|
||||||
"lucide-react": "^1.39.0",
|
"lucide-react": "^1.48.0",
|
||||||
"mermaid": "^11.17.2",
|
"mermaid": "^12.0.0",
|
||||||
"next": "16.3.4",
|
"next": "16.3.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.8",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.3.0",
|
||||||
"react-qr-code": "^2.2.0",
|
"react-qr-code": "^2.2.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
"zbsearch": "4.0.0",
|
"zbsearch": "4.0.0",
|
||||||
"zod": "^4.5.4"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/mdx": "^2.0.14",
|
"@types/mdx": "^2.0.14",
|
||||||
"@types/node": "^26.4.1",
|
"@types/node": "^26.6.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"oxfmt": "0.66.0",
|
"oxfmt": "0.70.0",
|
||||||
"oxlint": "1.81.0",
|
"oxlint": "1.85.0",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^5.0.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.25.0"
|
"packageManager": "pnpm@12.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1345
-1147
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,16 @@ minimumReleaseAgeExclude:
|
|||||||
- mermaid@11.17.0
|
- mermaid@11.17.0
|
||||||
- lucide-react@1.33.0
|
- lucide-react@1.33.0
|
||||||
- postcss@8.5.26
|
- postcss@8.5.26
|
||||||
- fumadocs-mdx@15.3.0
|
- fumadocs-mdx@15.3.0 || 15.4.1 || 15.4.5
|
||||||
- '@fumadocs/api-docs@0.2.7'
|
- '@fumadocs/api-docs@0.2.7 || 0.2.9'
|
||||||
- '@types/node@26.4.1'
|
- '@types/node@26.4.1'
|
||||||
- fumadocs-core@16.15.5
|
- fumadocs-core@16.15.5 || 16.15.11
|
||||||
- fumadocs-openapi@11.4.0
|
- fumadocs-openapi@11.4.0 || 11.4.3 || 12.0.3
|
||||||
- fumadocs-ui@16.15.5
|
- fumadocs-ui@16.15.5 || 16.15.11
|
||||||
|
- '@fumadocs/tailwind@0.1.2'
|
||||||
|
- '@fumari/image-size@0.1.1'
|
||||||
|
- '@fumari/stf@1.1.1'
|
||||||
|
- '@vitest/mocker@5.0.1 || 5.0.2'
|
||||||
|
- '@vitest/spy@5.0.1 || 5.0.2'
|
||||||
|
- fumadocs-docgen@3.1.1
|
||||||
|
- vitest@5.0.1 || 5.0.2
|
||||||
|
|||||||
+629
-5
@@ -69,6 +69,9 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"externalSubUserAgent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"externalTrafficInformEnable": {
|
"externalTrafficInformEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -288,6 +291,9 @@
|
|||||||
"subHappFallbackUrl": {
|
"subHappFallbackUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subHappLocalProxyAuth": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subHappNewUrl": {
|
"subHappNewUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -336,12 +342,97 @@
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyAnnounceUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyAppAutoDetect": {
|
||||||
|
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subIncyBannerBgColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyEnableRouting": {
|
"subIncyEnableRouting": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyFragmentInterval": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentLength": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentPackets": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentationEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideCheck": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoLimitEnabled": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesDelay": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesPacket": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppList": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPremiumUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyProfileDescription": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsDomain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsIp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyRoutingRules": {
|
"subIncyRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subIncySortOrder": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncySupportEmail": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subInfoNodeEnable": {
|
"subInfoNodeEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -395,6 +486,9 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -516,6 +610,7 @@
|
|||||||
"discordMemory",
|
"discordMemory",
|
||||||
"discordRunTime",
|
"discordRunTime",
|
||||||
"expireDiff",
|
"expireDiff",
|
||||||
|
"externalSubUserAgent",
|
||||||
"externalTrafficInformEnable",
|
"externalTrafficInformEnable",
|
||||||
"externalTrafficInformURI",
|
"externalTrafficInformURI",
|
||||||
"happLinkEnable",
|
"happLinkEnable",
|
||||||
@@ -583,6 +678,7 @@
|
|||||||
"subHappExcludeApns",
|
"subHappExcludeApns",
|
||||||
"subHappExcludeRoutes",
|
"subHappExcludeRoutes",
|
||||||
"subHappFallbackUrl",
|
"subHappFallbackUrl",
|
||||||
|
"subHappLocalProxyAuth",
|
||||||
"subHappNewUrl",
|
"subHappNewUrl",
|
||||||
"subHappNoLimit",
|
"subHappNoLimit",
|
||||||
"subHappNotificationExpire",
|
"subHappNotificationExpire",
|
||||||
@@ -599,8 +695,36 @@
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subIncyAnnounceUrl",
|
||||||
|
"subIncyAppAutoDetect",
|
||||||
|
"subIncyBannerBgColor",
|
||||||
|
"subIncyBannerButtonColor",
|
||||||
|
"subIncyBannerButtonText",
|
||||||
|
"subIncyBannerButtonUrl",
|
||||||
|
"subIncyBannerText",
|
||||||
"subIncyEnableRouting",
|
"subIncyEnableRouting",
|
||||||
|
"subIncyFragmentInterval",
|
||||||
|
"subIncyFragmentLength",
|
||||||
|
"subIncyFragmentPackets",
|
||||||
|
"subIncyFragmentationEnable",
|
||||||
|
"subIncyHideCheck",
|
||||||
|
"subIncyHideUrl",
|
||||||
|
"subIncyNoLimitEnabled",
|
||||||
|
"subIncyNoisesDelay",
|
||||||
|
"subIncyNoisesEnable",
|
||||||
|
"subIncyNoisesPacket",
|
||||||
|
"subIncyNoisesType",
|
||||||
|
"subIncyPerAppEnable",
|
||||||
|
"subIncyPerAppList",
|
||||||
|
"subIncyPerAppMode",
|
||||||
|
"subIncyPremiumUrl",
|
||||||
|
"subIncyProfileDescription",
|
||||||
|
"subIncyResolveDnsDomain",
|
||||||
|
"subIncyResolveDnsIp",
|
||||||
|
"subIncyResolveEnable",
|
||||||
"subIncyRoutingRules",
|
"subIncyRoutingRules",
|
||||||
|
"subIncySortOrder",
|
||||||
|
"subIncySupportEmail",
|
||||||
"subInfoNodeEnable",
|
"subInfoNodeEnable",
|
||||||
"subJsonAlwaysArray",
|
"subJsonAlwaysArray",
|
||||||
"subJsonAutoDetect",
|
"subJsonAutoDetect",
|
||||||
@@ -618,6 +742,7 @@
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -696,6 +821,9 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"externalSubUserAgent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"externalTrafficInformEnable": {
|
"externalTrafficInformEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -939,6 +1067,9 @@
|
|||||||
"subHappFallbackUrl": {
|
"subHappFallbackUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subHappLocalProxyAuth": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subHappNewUrl": {
|
"subHappNewUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -987,12 +1118,97 @@
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyAnnounceUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyAppAutoDetect": {
|
||||||
|
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subIncyBannerBgColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyEnableRouting": {
|
"subIncyEnableRouting": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyFragmentInterval": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentLength": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentPackets": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentationEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideCheck": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoLimitEnabled": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesDelay": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesPacket": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppList": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPremiumUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyProfileDescription": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsDomain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsIp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyRoutingRules": {
|
"subIncyRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subIncySortOrder": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncySupportEmail": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subInfoNodeEnable": {
|
"subInfoNodeEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -1046,6 +1262,9 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1167,6 +1386,7 @@
|
|||||||
"discordMemory",
|
"discordMemory",
|
||||||
"discordRunTime",
|
"discordRunTime",
|
||||||
"expireDiff",
|
"expireDiff",
|
||||||
|
"externalSubUserAgent",
|
||||||
"externalTrafficInformEnable",
|
"externalTrafficInformEnable",
|
||||||
"externalTrafficInformURI",
|
"externalTrafficInformURI",
|
||||||
"happLinkEnable",
|
"happLinkEnable",
|
||||||
@@ -1242,6 +1462,7 @@
|
|||||||
"subHappExcludeApns",
|
"subHappExcludeApns",
|
||||||
"subHappExcludeRoutes",
|
"subHappExcludeRoutes",
|
||||||
"subHappFallbackUrl",
|
"subHappFallbackUrl",
|
||||||
|
"subHappLocalProxyAuth",
|
||||||
"subHappNewUrl",
|
"subHappNewUrl",
|
||||||
"subHappNoLimit",
|
"subHappNoLimit",
|
||||||
"subHappNotificationExpire",
|
"subHappNotificationExpire",
|
||||||
@@ -1258,8 +1479,36 @@
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subIncyAnnounceUrl",
|
||||||
|
"subIncyAppAutoDetect",
|
||||||
|
"subIncyBannerBgColor",
|
||||||
|
"subIncyBannerButtonColor",
|
||||||
|
"subIncyBannerButtonText",
|
||||||
|
"subIncyBannerButtonUrl",
|
||||||
|
"subIncyBannerText",
|
||||||
"subIncyEnableRouting",
|
"subIncyEnableRouting",
|
||||||
|
"subIncyFragmentInterval",
|
||||||
|
"subIncyFragmentLength",
|
||||||
|
"subIncyFragmentPackets",
|
||||||
|
"subIncyFragmentationEnable",
|
||||||
|
"subIncyHideCheck",
|
||||||
|
"subIncyHideUrl",
|
||||||
|
"subIncyNoLimitEnabled",
|
||||||
|
"subIncyNoisesDelay",
|
||||||
|
"subIncyNoisesEnable",
|
||||||
|
"subIncyNoisesPacket",
|
||||||
|
"subIncyNoisesType",
|
||||||
|
"subIncyPerAppEnable",
|
||||||
|
"subIncyPerAppList",
|
||||||
|
"subIncyPerAppMode",
|
||||||
|
"subIncyPremiumUrl",
|
||||||
|
"subIncyProfileDescription",
|
||||||
|
"subIncyResolveDnsDomain",
|
||||||
|
"subIncyResolveDnsIp",
|
||||||
|
"subIncyResolveEnable",
|
||||||
"subIncyRoutingRules",
|
"subIncyRoutingRules",
|
||||||
|
"subIncySortOrder",
|
||||||
|
"subIncySupportEmail",
|
||||||
"subInfoNodeEnable",
|
"subInfoNodeEnable",
|
||||||
"subJsonAlwaysArray",
|
"subJsonAlwaysArray",
|
||||||
"subJsonAutoDetect",
|
"subJsonAutoDetect",
|
||||||
@@ -1277,6 +1526,7 @@
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -1515,13 +1765,17 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetDay": {
|
"resetDay": {
|
||||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetMax": {
|
"resetMax": {
|
||||||
"description": "Max auto-renew count, 0 = unlimited",
|
"description": "Max auto-renew count, 0 = unlimited",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"reverse": {
|
"reverse": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -1584,6 +1838,7 @@
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"security",
|
"security",
|
||||||
"subId",
|
"subId",
|
||||||
"tgId",
|
"tgId",
|
||||||
@@ -1735,6 +1990,9 @@
|
|||||||
"resetMax": {
|
"resetMax": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"reverse": {},
|
"reverse": {},
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1790,6 +2048,7 @@
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"reverse",
|
"reverse",
|
||||||
"secret",
|
"secret",
|
||||||
"security",
|
"security",
|
||||||
@@ -1803,6 +2062,97 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ClientRenewalPreview": {
|
||||||
|
"properties": {
|
||||||
|
"canRenew": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"delayedStart": {
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nextExpiry": {
|
||||||
|
"example": "2030-02-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"renewAt": {
|
||||||
|
"example": "2030-01-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"renewals": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"suggestedExpiry": {
|
||||||
|
"example": "2030-01-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"suggestedExpiryTime": {
|
||||||
|
"example": 1893456000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"example": "UTC",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"validThrough": {
|
||||||
|
"example": "2029-12-31T23:59:59Z",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"canRenew",
|
||||||
|
"delayedStart",
|
||||||
|
"nextExpiry",
|
||||||
|
"renewAt",
|
||||||
|
"renewals",
|
||||||
|
"suggestedExpiry",
|
||||||
|
"suggestedExpiryTime",
|
||||||
|
"timeZone",
|
||||||
|
"validThrough"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ClientRenewalPreviewRequest": {
|
||||||
|
"properties": {
|
||||||
|
"expiryTime": {
|
||||||
|
"example": 1893456000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"reset": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetCount": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetDay": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetMax": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expiryTime",
|
||||||
|
"reset",
|
||||||
|
"resetCount",
|
||||||
|
"resetDay",
|
||||||
|
"resetMax",
|
||||||
|
"resetWeekday"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ClientReverse": {
|
"ClientReverse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"tag": {
|
"tag": {
|
||||||
@@ -1873,6 +2223,10 @@
|
|||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"subId": {
|
"subId": {
|
||||||
"example": "abcd1234",
|
"example": "abcd1234",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1907,6 +2261,7 @@
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"subId",
|
"subId",
|
||||||
"totalGB",
|
"totalGB",
|
||||||
"updatedAt"
|
"updatedAt"
|
||||||
@@ -1962,7 +2317,7 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetDay": {
|
"resetDay": {
|
||||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
|
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
|
||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@@ -1971,6 +2326,11 @@
|
|||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"subId": {
|
"subId": {
|
||||||
"example": "i7tvdpeffi0hvvf1",
|
"example": "i7tvdpeffi0hvvf1",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2003,6 +2363,7 @@
|
|||||||
"resetCount",
|
"resetCount",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"subId",
|
"subId",
|
||||||
"total",
|
"total",
|
||||||
"up",
|
"up",
|
||||||
@@ -2293,6 +2654,9 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"cipherSuites": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2426,6 +2790,7 @@
|
|||||||
"address",
|
"address",
|
||||||
"allowInsecure",
|
"allowInsecure",
|
||||||
"alpn",
|
"alpn",
|
||||||
|
"cipherSuites",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"echConfigList",
|
"echConfigList",
|
||||||
"excludeFromSubTypes",
|
"excludeFromSubTypes",
|
||||||
@@ -2470,6 +2835,9 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"cipherSuites": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"echConfigList": {
|
"echConfigList": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2596,6 +2964,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"allowInsecure",
|
"allowInsecure",
|
||||||
"alpn",
|
"alpn",
|
||||||
|
"cipherSuites",
|
||||||
"echConfigList",
|
"echConfigList",
|
||||||
"excludeFromSubTypes",
|
"excludeFromSubTypes",
|
||||||
"finalMask",
|
"finalMask",
|
||||||
@@ -2685,6 +3054,11 @@
|
|||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"excludeFromSub": {
|
||||||
|
"description": "Whether to omit this inbound from subscription output while keeping it operational",
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"expiryTime": {
|
"expiryTime": {
|
||||||
"description": "Expiration timestamp",
|
"description": "Expiration timestamp",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -2808,6 +3182,7 @@
|
|||||||
"disableFlow",
|
"disableFlow",
|
||||||
"down",
|
"down",
|
||||||
"enable",
|
"enable",
|
||||||
|
"excludeFromSub",
|
||||||
"expiryTime",
|
"expiryTime",
|
||||||
"id",
|
"id",
|
||||||
"lastTrafficResetTime",
|
"lastTrafficResetTime",
|
||||||
@@ -4119,6 +4494,90 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Sponsor": {
|
||||||
|
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"example": true,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"example": "2026-10-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": "acme-2026-10",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"example": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"example": "/sponsors/logo/acme.png",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"example": "Acme VPS",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"until": {
|
||||||
|
"example": "2026-11-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"link",
|
||||||
|
"name",
|
||||||
|
"slots",
|
||||||
|
"text",
|
||||||
|
"title",
|
||||||
|
"until"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SponsorList": {
|
||||||
|
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||||
|
"properties": {
|
||||||
|
"contact": {
|
||||||
|
"example": "https://t.me/example",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Sponsor"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"sponsors"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubBalancer": {
|
"SubBalancer": {
|
||||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4571,6 +5030,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sponsors": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
],
|
||||||
|
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
|
||||||
|
"operationId": "get_sponsors",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/SponsorList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"contact": "https://t.me/example",
|
||||||
|
"sponsors": [
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"from": "2026-10-01T00:00:00Z",
|
||||||
|
"id": "acme-2026-10",
|
||||||
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
"name": "Acme VPS",
|
||||||
|
"slots": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"text": {},
|
||||||
|
"title": {},
|
||||||
|
"until": "2026-11-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/getTwoFactorEnable": {
|
"/getTwoFactorEnable": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4652,6 +5165,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -4661,6 +5175,7 @@
|
|||||||
"disableFlow": false,
|
"disableFlow": false,
|
||||||
"down": 0,
|
"down": 0,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"excludeFromSub": false,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"fallbackParent": null,
|
"fallbackParent": null,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -7886,6 +8401,7 @@
|
|||||||
"reset": 0,
|
"reset": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "abcd1234",
|
"subId": "abcd1234",
|
||||||
"totalGB": 53687091200,
|
"totalGB": 53687091200,
|
||||||
"traffic": null,
|
"traffic": null,
|
||||||
@@ -8078,6 +8594,97 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/clients/renewalPreview": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Clients"
|
||||||
|
],
|
||||||
|
"summary": "Preview client auto-renewal dates without saving or resetting anything.",
|
||||||
|
"operationId": "post_panel_api_clients_renewalPreview",
|
||||||
|
"description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expiryTime": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
|
||||||
|
},
|
||||||
|
"reset": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Fixed interval in days; 0 disabled."
|
||||||
|
},
|
||||||
|
"resetDay": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Monthly calendar day 1-31; 0 disabled."
|
||||||
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
|
||||||
|
},
|
||||||
|
"resetMax": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum renewals; 0 unlimited."
|
||||||
|
},
|
||||||
|
"resetCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Renewals already consumed; defaults to 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expiryTime",
|
||||||
|
"reset",
|
||||||
|
"resetDay",
|
||||||
|
"resetWeekday",
|
||||||
|
"resetMax",
|
||||||
|
"resetCount"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/ClientRenewalPreview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"canRenew": true,
|
||||||
|
"delayedStart": false,
|
||||||
|
"nextExpiry": "2030-02-01T00:00:00Z",
|
||||||
|
"renewAt": "2030-01-01T00:00:00Z",
|
||||||
|
"renewals": 1,
|
||||||
|
"suggestedExpiry": "2030-01-01T00:00:00Z",
|
||||||
|
"suggestedExpiryTime": 1893456000000,
|
||||||
|
"timeZone": "UTC",
|
||||||
|
"validThrough": "2029-12-31T23:59:59Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/update/{email}": {
|
"/panel/api/clients/update/{email}": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -8537,7 +9144,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
|
"summary": "Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
|
||||||
"operationId": "get_panel_api_clients_export",
|
"operationId": "get_panel_api_clients_export",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -8572,7 +9179,13 @@
|
|||||||
"inboundIds": [
|
"inboundIds": [
|
||||||
7,
|
7,
|
||||||
9
|
9
|
||||||
]
|
],
|
||||||
|
"traffic": {
|
||||||
|
"up": 1048576,
|
||||||
|
"down": 2097152,
|
||||||
|
"resetCount": 0,
|
||||||
|
"lastOnline": 1735680000000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8587,7 +9200,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.",
|
"summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.",
|
||||||
"operationId": "post_panel_api_clients_import",
|
"operationId": "post_panel_api_clients_import",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -10137,6 +10750,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -11260,6 +11874,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
""
|
""
|
||||||
@@ -11354,6 +11969,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
""
|
""
|
||||||
@@ -11451,6 +12067,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
""
|
""
|
||||||
@@ -11601,6 +12218,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
@@ -11722,6 +12340,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
@@ -11973,6 +12592,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
@@ -15504,6 +16124,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -15586,6 +16207,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -15632,6 +16254,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -15641,6 +16264,7 @@
|
|||||||
"disableFlow": false,
|
"disableFlow": false,
|
||||||
"down": 0,
|
"down": 0,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"excludeFromSub": false,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"fallbackParent": null,
|
"fallbackParent": null,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ export const withTheme: Decorator = (Story, context) => {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
document.body.classList.remove('dark', 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
document.body.classList.add(dark ? 'dark' : 'light');
|
document.body.classList.add(dark ? 'dark' : 'light');
|
||||||
|
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
|
// The click wave outlives its story and re-renders from a ResizeObserver
|
||||||
|
// inside the next story's act(), tripping React's act-environment warning.
|
||||||
|
<ConfigProvider theme={buildAntdThemeConfig(dark, false)} wave={{ disabled: true }}>
|
||||||
<div style={{ padding: 24, minWidth: 320 }}>
|
<div style={{ padding: 24, minWidth: 320 }}>
|
||||||
<Story />
|
<Story />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+1164
-830
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -5,8 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0",
|
"node": ">=26.0.0",
|
||||||
"npm": ">=10.0.0"
|
"npm": ">=11.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -39,9 +39,9 @@
|
|||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@hookform/resolvers": "^5.9.1",
|
"@hookform/resolvers": "^5.9.1",
|
||||||
"@noble/hashes": "^2.4.0",
|
"@noble/hashes": "^2.4.0",
|
||||||
"@tanstack/react-query": "^5.102.8",
|
"@tanstack/react-query": "^5.103.2",
|
||||||
"@tanstack/react-query-devtools": "^5.102.8",
|
"@tanstack/react-query-devtools": "^5.103.2",
|
||||||
"antd": "^6.6.4",
|
"antd": "^6.6.5",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.23",
|
"dayjs": "^1.11.23",
|
||||||
"i18next": "^26.4.2",
|
"i18next": "^26.4.2",
|
||||||
@@ -50,9 +50,9 @@
|
|||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-hook-form": "^7.88.0",
|
"react-hook-form": "^7.88.0",
|
||||||
"react-i18next": "^17.0.14",
|
"react-i18next": "^17.0.15",
|
||||||
"react-router": "^8.3.1",
|
"react-router": "^8.4.0",
|
||||||
"swagger-ui-react": "^5.32.15",
|
"swagger-ui-react": "^5.33.0",
|
||||||
"uplot": "^1.6.32",
|
"uplot": "^1.6.32",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
@@ -67,20 +67,20 @@
|
|||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"@vitest/browser-playwright": "5.0.0",
|
"@vitest/browser-playwright": "5.0.2",
|
||||||
"@vitest/coverage-v8": "^5.0.0",
|
"@vitest/coverage-v8": "^5.0.2",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.1.1",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.5.1",
|
||||||
"msw": "^2.15.0",
|
"msw": "^2.15.0",
|
||||||
"oxfmt": "0.68.0",
|
"oxfmt": "0.70.0",
|
||||||
"oxlint": "1.83.0",
|
"oxlint": "1.85.0",
|
||||||
"oxlint-tsgolint": "^7.0.2001",
|
"oxlint-tsgolint": "^7.0.2003",
|
||||||
"playwright": "^1.63.0",
|
"playwright": "^1.63.0",
|
||||||
"storybook": "^10.6.0",
|
"storybook": "^10.6.0",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.3.0",
|
"vite": "8.3.1",
|
||||||
"vitest": "^5.0.0"
|
"vitest": "^5.0.2"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"dompurify": "^3.4.11",
|
"dompurify": "^3.4.11",
|
||||||
@@ -98,8 +98,8 @@
|
|||||||
},
|
},
|
||||||
"@storybook/addon-vitest": {
|
"@storybook/addon-vitest": {
|
||||||
"vitest": "^5.0.0",
|
"vitest": "^5.0.0",
|
||||||
"@vitest/browser-playwright": "5.0.0",
|
"@vitest/browser-playwright": "^5.0.0",
|
||||||
"@vitest/browser": "5.0.0"
|
"@vitest/browser": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"externalSubUserAgent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"externalTrafficInformEnable": {
|
"externalTrafficInformEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -288,6 +291,9 @@
|
|||||||
"subHappFallbackUrl": {
|
"subHappFallbackUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subHappLocalProxyAuth": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subHappNewUrl": {
|
"subHappNewUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -336,12 +342,97 @@
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyAnnounceUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyAppAutoDetect": {
|
||||||
|
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subIncyBannerBgColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyEnableRouting": {
|
"subIncyEnableRouting": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyFragmentInterval": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentLength": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentPackets": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentationEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideCheck": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoLimitEnabled": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesDelay": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesPacket": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppList": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPremiumUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyProfileDescription": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsDomain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsIp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyRoutingRules": {
|
"subIncyRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subIncySortOrder": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncySupportEmail": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subInfoNodeEnable": {
|
"subInfoNodeEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -395,6 +486,9 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -516,6 +610,7 @@
|
|||||||
"discordMemory",
|
"discordMemory",
|
||||||
"discordRunTime",
|
"discordRunTime",
|
||||||
"expireDiff",
|
"expireDiff",
|
||||||
|
"externalSubUserAgent",
|
||||||
"externalTrafficInformEnable",
|
"externalTrafficInformEnable",
|
||||||
"externalTrafficInformURI",
|
"externalTrafficInformURI",
|
||||||
"happLinkEnable",
|
"happLinkEnable",
|
||||||
@@ -583,6 +678,7 @@
|
|||||||
"subHappExcludeApns",
|
"subHappExcludeApns",
|
||||||
"subHappExcludeRoutes",
|
"subHappExcludeRoutes",
|
||||||
"subHappFallbackUrl",
|
"subHappFallbackUrl",
|
||||||
|
"subHappLocalProxyAuth",
|
||||||
"subHappNewUrl",
|
"subHappNewUrl",
|
||||||
"subHappNoLimit",
|
"subHappNoLimit",
|
||||||
"subHappNotificationExpire",
|
"subHappNotificationExpire",
|
||||||
@@ -599,8 +695,36 @@
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subIncyAnnounceUrl",
|
||||||
|
"subIncyAppAutoDetect",
|
||||||
|
"subIncyBannerBgColor",
|
||||||
|
"subIncyBannerButtonColor",
|
||||||
|
"subIncyBannerButtonText",
|
||||||
|
"subIncyBannerButtonUrl",
|
||||||
|
"subIncyBannerText",
|
||||||
"subIncyEnableRouting",
|
"subIncyEnableRouting",
|
||||||
|
"subIncyFragmentInterval",
|
||||||
|
"subIncyFragmentLength",
|
||||||
|
"subIncyFragmentPackets",
|
||||||
|
"subIncyFragmentationEnable",
|
||||||
|
"subIncyHideCheck",
|
||||||
|
"subIncyHideUrl",
|
||||||
|
"subIncyNoLimitEnabled",
|
||||||
|
"subIncyNoisesDelay",
|
||||||
|
"subIncyNoisesEnable",
|
||||||
|
"subIncyNoisesPacket",
|
||||||
|
"subIncyNoisesType",
|
||||||
|
"subIncyPerAppEnable",
|
||||||
|
"subIncyPerAppList",
|
||||||
|
"subIncyPerAppMode",
|
||||||
|
"subIncyPremiumUrl",
|
||||||
|
"subIncyProfileDescription",
|
||||||
|
"subIncyResolveDnsDomain",
|
||||||
|
"subIncyResolveDnsIp",
|
||||||
|
"subIncyResolveEnable",
|
||||||
"subIncyRoutingRules",
|
"subIncyRoutingRules",
|
||||||
|
"subIncySortOrder",
|
||||||
|
"subIncySupportEmail",
|
||||||
"subInfoNodeEnable",
|
"subInfoNodeEnable",
|
||||||
"subJsonAlwaysArray",
|
"subJsonAlwaysArray",
|
||||||
"subJsonAutoDetect",
|
"subJsonAutoDetect",
|
||||||
@@ -618,6 +742,7 @@
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -696,6 +821,9 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"externalSubUserAgent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"externalTrafficInformEnable": {
|
"externalTrafficInformEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -939,6 +1067,9 @@
|
|||||||
"subHappFallbackUrl": {
|
"subHappFallbackUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subHappLocalProxyAuth": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subHappNewUrl": {
|
"subHappNewUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -987,12 +1118,97 @@
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyAnnounceUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyAppAutoDetect": {
|
||||||
|
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subIncyBannerBgColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyEnableRouting": {
|
"subIncyEnableRouting": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyFragmentInterval": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentLength": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentPackets": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentationEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideCheck": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoLimitEnabled": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesDelay": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesPacket": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppList": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPremiumUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyProfileDescription": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsDomain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsIp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyRoutingRules": {
|
"subIncyRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subIncySortOrder": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncySupportEmail": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subInfoNodeEnable": {
|
"subInfoNodeEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -1046,6 +1262,9 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1167,6 +1386,7 @@
|
|||||||
"discordMemory",
|
"discordMemory",
|
||||||
"discordRunTime",
|
"discordRunTime",
|
||||||
"expireDiff",
|
"expireDiff",
|
||||||
|
"externalSubUserAgent",
|
||||||
"externalTrafficInformEnable",
|
"externalTrafficInformEnable",
|
||||||
"externalTrafficInformURI",
|
"externalTrafficInformURI",
|
||||||
"happLinkEnable",
|
"happLinkEnable",
|
||||||
@@ -1242,6 +1462,7 @@
|
|||||||
"subHappExcludeApns",
|
"subHappExcludeApns",
|
||||||
"subHappExcludeRoutes",
|
"subHappExcludeRoutes",
|
||||||
"subHappFallbackUrl",
|
"subHappFallbackUrl",
|
||||||
|
"subHappLocalProxyAuth",
|
||||||
"subHappNewUrl",
|
"subHappNewUrl",
|
||||||
"subHappNoLimit",
|
"subHappNoLimit",
|
||||||
"subHappNotificationExpire",
|
"subHappNotificationExpire",
|
||||||
@@ -1258,8 +1479,36 @@
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subIncyAnnounceUrl",
|
||||||
|
"subIncyAppAutoDetect",
|
||||||
|
"subIncyBannerBgColor",
|
||||||
|
"subIncyBannerButtonColor",
|
||||||
|
"subIncyBannerButtonText",
|
||||||
|
"subIncyBannerButtonUrl",
|
||||||
|
"subIncyBannerText",
|
||||||
"subIncyEnableRouting",
|
"subIncyEnableRouting",
|
||||||
|
"subIncyFragmentInterval",
|
||||||
|
"subIncyFragmentLength",
|
||||||
|
"subIncyFragmentPackets",
|
||||||
|
"subIncyFragmentationEnable",
|
||||||
|
"subIncyHideCheck",
|
||||||
|
"subIncyHideUrl",
|
||||||
|
"subIncyNoLimitEnabled",
|
||||||
|
"subIncyNoisesDelay",
|
||||||
|
"subIncyNoisesEnable",
|
||||||
|
"subIncyNoisesPacket",
|
||||||
|
"subIncyNoisesType",
|
||||||
|
"subIncyPerAppEnable",
|
||||||
|
"subIncyPerAppList",
|
||||||
|
"subIncyPerAppMode",
|
||||||
|
"subIncyPremiumUrl",
|
||||||
|
"subIncyProfileDescription",
|
||||||
|
"subIncyResolveDnsDomain",
|
||||||
|
"subIncyResolveDnsIp",
|
||||||
|
"subIncyResolveEnable",
|
||||||
"subIncyRoutingRules",
|
"subIncyRoutingRules",
|
||||||
|
"subIncySortOrder",
|
||||||
|
"subIncySupportEmail",
|
||||||
"subInfoNodeEnable",
|
"subInfoNodeEnable",
|
||||||
"subJsonAlwaysArray",
|
"subJsonAlwaysArray",
|
||||||
"subJsonAutoDetect",
|
"subJsonAutoDetect",
|
||||||
@@ -1277,6 +1526,7 @@
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -1515,13 +1765,17 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetDay": {
|
"resetDay": {
|
||||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetMax": {
|
"resetMax": {
|
||||||
"description": "Max auto-renew count, 0 = unlimited",
|
"description": "Max auto-renew count, 0 = unlimited",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"reverse": {
|
"reverse": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -1584,6 +1838,7 @@
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"security",
|
"security",
|
||||||
"subId",
|
"subId",
|
||||||
"tgId",
|
"tgId",
|
||||||
@@ -1735,6 +1990,9 @@
|
|||||||
"resetMax": {
|
"resetMax": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"reverse": {},
|
"reverse": {},
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1790,6 +2048,7 @@
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"reverse",
|
"reverse",
|
||||||
"secret",
|
"secret",
|
||||||
"security",
|
"security",
|
||||||
@@ -1803,6 +2062,97 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ClientRenewalPreview": {
|
||||||
|
"properties": {
|
||||||
|
"canRenew": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"delayedStart": {
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nextExpiry": {
|
||||||
|
"example": "2030-02-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"renewAt": {
|
||||||
|
"example": "2030-01-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"renewals": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"suggestedExpiry": {
|
||||||
|
"example": "2030-01-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"suggestedExpiryTime": {
|
||||||
|
"example": 1893456000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"example": "UTC",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"validThrough": {
|
||||||
|
"example": "2029-12-31T23:59:59Z",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"canRenew",
|
||||||
|
"delayedStart",
|
||||||
|
"nextExpiry",
|
||||||
|
"renewAt",
|
||||||
|
"renewals",
|
||||||
|
"suggestedExpiry",
|
||||||
|
"suggestedExpiryTime",
|
||||||
|
"timeZone",
|
||||||
|
"validThrough"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ClientRenewalPreviewRequest": {
|
||||||
|
"properties": {
|
||||||
|
"expiryTime": {
|
||||||
|
"example": 1893456000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"reset": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetCount": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetDay": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetMax": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expiryTime",
|
||||||
|
"reset",
|
||||||
|
"resetCount",
|
||||||
|
"resetDay",
|
||||||
|
"resetMax",
|
||||||
|
"resetWeekday"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ClientReverse": {
|
"ClientReverse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"tag": {
|
"tag": {
|
||||||
@@ -1873,6 +2223,10 @@
|
|||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"subId": {
|
"subId": {
|
||||||
"example": "abcd1234",
|
"example": "abcd1234",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1907,6 +2261,7 @@
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"subId",
|
"subId",
|
||||||
"totalGB",
|
"totalGB",
|
||||||
"updatedAt"
|
"updatedAt"
|
||||||
@@ -1962,7 +2317,7 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetDay": {
|
"resetDay": {
|
||||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
|
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
|
||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@@ -1971,6 +2326,11 @@
|
|||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"subId": {
|
"subId": {
|
||||||
"example": "i7tvdpeffi0hvvf1",
|
"example": "i7tvdpeffi0hvvf1",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2003,6 +2363,7 @@
|
|||||||
"resetCount",
|
"resetCount",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"subId",
|
"subId",
|
||||||
"total",
|
"total",
|
||||||
"up",
|
"up",
|
||||||
@@ -2293,6 +2654,9 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"cipherSuites": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2426,6 +2790,7 @@
|
|||||||
"address",
|
"address",
|
||||||
"allowInsecure",
|
"allowInsecure",
|
||||||
"alpn",
|
"alpn",
|
||||||
|
"cipherSuites",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"echConfigList",
|
"echConfigList",
|
||||||
"excludeFromSubTypes",
|
"excludeFromSubTypes",
|
||||||
@@ -2470,6 +2835,9 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"cipherSuites": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"echConfigList": {
|
"echConfigList": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2596,6 +2964,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"allowInsecure",
|
"allowInsecure",
|
||||||
"alpn",
|
"alpn",
|
||||||
|
"cipherSuites",
|
||||||
"echConfigList",
|
"echConfigList",
|
||||||
"excludeFromSubTypes",
|
"excludeFromSubTypes",
|
||||||
"finalMask",
|
"finalMask",
|
||||||
@@ -2685,6 +3054,11 @@
|
|||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"excludeFromSub": {
|
||||||
|
"description": "Whether to omit this inbound from subscription output while keeping it operational",
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"expiryTime": {
|
"expiryTime": {
|
||||||
"description": "Expiration timestamp",
|
"description": "Expiration timestamp",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -2808,6 +3182,7 @@
|
|||||||
"disableFlow",
|
"disableFlow",
|
||||||
"down",
|
"down",
|
||||||
"enable",
|
"enable",
|
||||||
|
"excludeFromSub",
|
||||||
"expiryTime",
|
"expiryTime",
|
||||||
"id",
|
"id",
|
||||||
"lastTrafficResetTime",
|
"lastTrafficResetTime",
|
||||||
@@ -4119,6 +4494,90 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Sponsor": {
|
||||||
|
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"example": true,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"example": "2026-10-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": "acme-2026-10",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"example": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"example": "/sponsors/logo/acme.png",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"example": "Acme VPS",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"until": {
|
||||||
|
"example": "2026-11-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"link",
|
||||||
|
"name",
|
||||||
|
"slots",
|
||||||
|
"text",
|
||||||
|
"title",
|
||||||
|
"until"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SponsorList": {
|
||||||
|
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||||
|
"properties": {
|
||||||
|
"contact": {
|
||||||
|
"example": "https://t.me/example",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Sponsor"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"sponsors"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubBalancer": {
|
"SubBalancer": {
|
||||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4571,6 +5030,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/sponsors": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
],
|
||||||
|
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
|
||||||
|
"operationId": "get_sponsors",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/SponsorList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"contact": "https://t.me/example",
|
||||||
|
"sponsors": [
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"from": "2026-10-01T00:00:00Z",
|
||||||
|
"id": "acme-2026-10",
|
||||||
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
"name": "Acme VPS",
|
||||||
|
"slots": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"text": {},
|
||||||
|
"title": {},
|
||||||
|
"until": "2026-11-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/getTwoFactorEnable": {
|
"/getTwoFactorEnable": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4652,6 +5165,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -4661,6 +5175,7 @@
|
|||||||
"disableFlow": false,
|
"disableFlow": false,
|
||||||
"down": 0,
|
"down": 0,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"excludeFromSub": false,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"fallbackParent": null,
|
"fallbackParent": null,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -7886,6 +8401,7 @@
|
|||||||
"reset": 0,
|
"reset": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "abcd1234",
|
"subId": "abcd1234",
|
||||||
"totalGB": 53687091200,
|
"totalGB": 53687091200,
|
||||||
"traffic": null,
|
"traffic": null,
|
||||||
@@ -8078,6 +8594,97 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/clients/renewalPreview": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Clients"
|
||||||
|
],
|
||||||
|
"summary": "Preview client auto-renewal dates without saving or resetting anything.",
|
||||||
|
"operationId": "post_panel_api_clients_renewalPreview",
|
||||||
|
"description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expiryTime": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
|
||||||
|
},
|
||||||
|
"reset": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Fixed interval in days; 0 disabled."
|
||||||
|
},
|
||||||
|
"resetDay": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Monthly calendar day 1-31; 0 disabled."
|
||||||
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
|
||||||
|
},
|
||||||
|
"resetMax": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum renewals; 0 unlimited."
|
||||||
|
},
|
||||||
|
"resetCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Renewals already consumed; defaults to 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expiryTime",
|
||||||
|
"reset",
|
||||||
|
"resetDay",
|
||||||
|
"resetWeekday",
|
||||||
|
"resetMax",
|
||||||
|
"resetCount"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/ClientRenewalPreview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"canRenew": true,
|
||||||
|
"delayedStart": false,
|
||||||
|
"nextExpiry": "2030-02-01T00:00:00Z",
|
||||||
|
"renewAt": "2030-01-01T00:00:00Z",
|
||||||
|
"renewals": 1,
|
||||||
|
"suggestedExpiry": "2030-01-01T00:00:00Z",
|
||||||
|
"suggestedExpiryTime": 1893456000000,
|
||||||
|
"timeZone": "UTC",
|
||||||
|
"validThrough": "2029-12-31T23:59:59Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/update/{email}": {
|
"/panel/api/clients/update/{email}": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -8537,7 +9144,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
|
"summary": "Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
|
||||||
"operationId": "get_panel_api_clients_export",
|
"operationId": "get_panel_api_clients_export",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -8572,7 +9179,13 @@
|
|||||||
"inboundIds": [
|
"inboundIds": [
|
||||||
7,
|
7,
|
||||||
9
|
9
|
||||||
]
|
],
|
||||||
|
"traffic": {
|
||||||
|
"up": 1048576,
|
||||||
|
"down": 2097152,
|
||||||
|
"resetCount": 0,
|
||||||
|
"lastOnline": 1735680000000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8587,7 +9200,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.",
|
"summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.",
|
||||||
"operationId": "post_panel_api_clients_import",
|
"operationId": "post_panel_api_clients_import",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -10137,6 +10750,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -11260,6 +11874,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
""
|
""
|
||||||
@@ -11354,6 +11969,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
""
|
""
|
||||||
@@ -11451,6 +12067,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
""
|
""
|
||||||
@@ -11601,6 +12218,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
@@ -11722,6 +12340,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
@@ -11973,6 +12592,7 @@
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
@@ -15504,6 +16124,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -15586,6 +16207,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -15632,6 +16254,7 @@
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -15641,6 +16264,7 @@
|
|||||||
"disableFlow": false,
|
"disableFlow": false,
|
||||||
"down": 0,
|
"down": 0,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"excludeFromSub": false,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"fallbackParent": null,
|
"fallbackParent": null,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ export async function httpRequest(
|
|||||||
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
|
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withBasePath(path: string): string {
|
||||||
|
return basePathPrefix + path;
|
||||||
|
}
|
||||||
|
|
||||||
export function setupHttp(): void {
|
export function setupHttp(): void {
|
||||||
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
||||||
if (!basePath) {
|
if (!basePath) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import type { SponsorList } from '@/generated/types';
|
||||||
|
|
||||||
|
const EMPTY: SponsorList = { sponsors: [] };
|
||||||
|
|
||||||
|
async function fetchSponsors(): Promise<SponsorList> {
|
||||||
|
const msg = await HttpUtil.get<SponsorList>('/sponsors', undefined, { silent: true });
|
||||||
|
if (!msg?.success || !msg.obj) return EMPTY;
|
||||||
|
return { contact: msg.obj.contact, sponsors: msg.obj.sponsors ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSponsorsQuery() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: keys.sponsors(),
|
||||||
|
queryFn: fetchSponsors,
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
return { data: query.data ?? EMPTY, fetched: query.isFetched };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const keys = {
|
export const keys = {
|
||||||
|
sponsors: () => ['sponsors'] as const,
|
||||||
server: {
|
server: {
|
||||||
status: () => ['server', 'status'] as const,
|
status: () => ['server', 'status'] as const,
|
||||||
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
|
CrownOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
DiscordOutlined,
|
DiscordOutlined,
|
||||||
@@ -418,6 +419,12 @@ export default function CommandPalette() {
|
|||||||
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||||
icon: <ApiOutlined />,
|
icon: <ApiOutlined />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/sponsors',
|
||||||
|
title: t('menu.sponsors'),
|
||||||
|
keywords: ['sponsors', 'sponsor', 'partners'],
|
||||||
|
icon: <CrownOutlined />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pages
|
pages
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Select } from 'antd';
|
||||||
|
import type { SelectProps } from 'antd';
|
||||||
|
|
||||||
|
import { TLS_CIPHER_OPTION } from '@/schemas/primitives';
|
||||||
|
|
||||||
|
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
|
||||||
|
|
||||||
|
type CipherSuitesSelectProps = Omit<
|
||||||
|
SelectProps<string[]>,
|
||||||
|
'value' | 'onChange' | 'mode' | 'options'
|
||||||
|
> & {
|
||||||
|
// Injected by FormField:
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
|
||||||
|
// the stored value stays the single colon-joined string xray reads.
|
||||||
|
export default function CipherSuitesSelect({
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
...rest
|
||||||
|
}: CipherSuitesSelectProps) {
|
||||||
|
const suites = value
|
||||||
|
.split(':')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
tokenSeparators={[':', ',']}
|
||||||
|
{...rest}
|
||||||
|
mode="tags"
|
||||||
|
options={CIPHER_SUITE_OPTIONS}
|
||||||
|
value={suites}
|
||||||
|
onChange={(next) => onChange?.(next.join(':'))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export { default as JsonEditor } from './JsonEditor';
|
|||||||
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
||||||
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
|
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
|
||||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||||
|
export { default as CipherSuitesSelect } from './CipherSuitesSelect';
|
||||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||||
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
|
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types'
|
|||||||
import './GeoBrowserModal.css';
|
import './GeoBrowserModal.css';
|
||||||
|
|
||||||
const ENTRY_PAGE_SIZE = 100;
|
const ENTRY_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
// Attributes are dropped server-side, so kind:value repeats within real
|
||||||
|
// geosite categories; the page position is the only unique row key.
|
||||||
|
type GeoEntryRow = GeoEntry & { position: number };
|
||||||
const CATEGORY_SCROLL_HEIGHT = 438;
|
const CATEGORY_SCROLL_HEIGHT = 438;
|
||||||
const ENTRY_FILTER_DELAY = 500;
|
const ENTRY_FILTER_DELAY = 500;
|
||||||
|
|
||||||
@@ -224,7 +228,12 @@ export default function GeoBrowserModal({
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const entryColumns: ColumnsType<GeoEntry> = useMemo(
|
const entryRows: GeoEntryRow[] = useMemo(
|
||||||
|
() => (entriesQuery.data?.items ?? []).map((entry, position) => ({ ...entry, position })),
|
||||||
|
[entriesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const entryColumns: ColumnsType<GeoEntryRow> = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
dataIndex: 'kind',
|
dataIndex: 'kind',
|
||||||
@@ -391,9 +400,9 @@ export default function GeoBrowserModal({
|
|||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
rowKey={(entry, index) => `${entry.value}-${index}`}
|
rowKey="position"
|
||||||
columns={entryColumns}
|
columns={entryColumns}
|
||||||
dataSource={entriesQuery.data?.items ?? []}
|
dataSource={entryRows}
|
||||||
loading={entriesQuery.isLoading}
|
loading={entriesQuery.isLoading}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: entriesQuery.isError
|
emptyText: entriesQuery.isError
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
.sponsor-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
|
border-radius: var(--ant-border-radius-lg, 8px);
|
||||||
|
background: var(--bg-card, var(--ant-color-fill-quaternary));
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card:hover {
|
||||||
|
border-color: var(--ant-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-main:hover,
|
||||||
|
.sponsor-main:focus-visible {
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-logo {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--ant-color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-logo-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ant-color-primary);
|
||||||
|
background: var(--ant-color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--ant-color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-title {
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-visit {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ant-color-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 6px 6px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-close:hover,
|
||||||
|
.sponsor-close:focus-visible {
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
background: var(--ant-color-fill-tertiary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir='rtl'] .sponsor-close {
|
||||||
|
margin: 6px 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-main {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-title {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: normal;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-text {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-compact .sponsor-close {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin: 4px 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-visit {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-icon {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-icon .sponsor-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.sponsor-card-banner .sponsor-main {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-banner .sponsor-visit {
|
||||||
|
flex-basis: 100%;
|
||||||
|
padding-inline-start: 52px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card {
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
|
||||||
|
import type { Sponsor } from '@/generated/types';
|
||||||
|
import SponsorCard from './SponsorCard';
|
||||||
|
|
||||||
|
const sponsor: Sponsor = {
|
||||||
|
id: 'acme-2026-10',
|
||||||
|
name: 'Acme VPS',
|
||||||
|
slots: ['dashboard', 'sidebar', 'page', 'login'],
|
||||||
|
until: '2099-01-01T00:00:00Z',
|
||||||
|
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
|
||||||
|
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
|
||||||
|
link: 'https://acme.example/?utm_source=3x-ui',
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Sponsor/SponsorCard',
|
||||||
|
component: SponsorCard,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Paid sponsor placement fed by the project sponsors.json. Always labelled as a sponsor; links open in a new tab with rel="sponsored".',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
|
||||||
|
variant: {
|
||||||
|
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
|
||||||
|
},
|
||||||
|
iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
|
||||||
|
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
|
||||||
|
},
|
||||||
|
args: { sponsor },
|
||||||
|
} satisfies Meta<typeof SponsorCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Banner: Story = {
|
||||||
|
args: { variant: 'banner', onClose: () => {} },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByText('Sponsor')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer sponsored');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Compact: Story = {
|
||||||
|
args: { variant: 'compact', onClose: () => {} },
|
||||||
|
render: (args) => (
|
||||||
|
<div style={{ width: 204 }}>
|
||||||
|
<SponsorCard {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Card: Story = {
|
||||||
|
args: { variant: 'card' },
|
||||||
|
render: (args) => (
|
||||||
|
<div style={{ width: 320 }}>
|
||||||
|
<SponsorCard {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IconOnly: Story = {
|
||||||
|
args: { iconOnly: true },
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { CloseOutlined, ExportOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { withBasePath } from '@/api/http-init';
|
||||||
|
import type { Sponsor } from '@/generated/types';
|
||||||
|
import { pickLocale } from '@/lib/sponsors';
|
||||||
|
import './SponsorCard.css';
|
||||||
|
|
||||||
|
export type SponsorCardVariant = 'banner' | 'compact' | 'card';
|
||||||
|
|
||||||
|
export interface SponsorCardProps {
|
||||||
|
sponsor: Sponsor;
|
||||||
|
variant?: SponsorCardVariant;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
|
||||||
|
const [failedSrc, setFailedSrc] = useState('');
|
||||||
|
if (sponsor.logo && failedSrc !== sponsor.logo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="sponsor-logo"
|
||||||
|
src={withBasePath(sponsor.logo)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setFailedSrc(sponsor.logo ?? '')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="sponsor-logo sponsor-logo-fallback" aria-hidden="true">
|
||||||
|
{sponsor.name.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SponsorCard({
|
||||||
|
sponsor,
|
||||||
|
variant = 'banner',
|
||||||
|
iconOnly = false,
|
||||||
|
onClose,
|
||||||
|
}: SponsorCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = i18n.resolvedLanguage || i18n.language || 'en';
|
||||||
|
const title = pickLocale(sponsor.title, lang) || sponsor.name;
|
||||||
|
const text = pickLocale(sponsor.text, lang);
|
||||||
|
const tag = t('pages.sponsors.tag');
|
||||||
|
|
||||||
|
if (iconOnly) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="sponsor-card sponsor-card-icon"
|
||||||
|
href={sponsor.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer sponsored"
|
||||||
|
title={`${tag} · ${title}`}
|
||||||
|
aria-label={`${tag}: ${title}`}
|
||||||
|
>
|
||||||
|
<SponsorLogo sponsor={sponsor} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sponsor-card sponsor-card-${variant}`}>
|
||||||
|
<a
|
||||||
|
className="sponsor-main"
|
||||||
|
href={sponsor.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer sponsored"
|
||||||
|
>
|
||||||
|
<SponsorLogo sponsor={sponsor} />
|
||||||
|
<span className="sponsor-body" dir="auto">
|
||||||
|
<span className="sponsor-head">
|
||||||
|
<span className="sponsor-tag">{tag}</span>
|
||||||
|
<span className="sponsor-title">{title}</span>
|
||||||
|
</span>
|
||||||
|
{text && <span className="sponsor-text">{text}</span>}
|
||||||
|
</span>
|
||||||
|
{variant !== 'compact' && (
|
||||||
|
<span className="sponsor-visit">
|
||||||
|
{t('pages.sponsors.visit')} <ExportOutlined />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sponsor-close"
|
||||||
|
aria-label={t('close')}
|
||||||
|
title={t('close')}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||||
|
import {
|
||||||
|
dismissSponsor,
|
||||||
|
isSponsorDismissed,
|
||||||
|
sponsorsForSlot,
|
||||||
|
type SponsorSlot as Slot,
|
||||||
|
} from '@/lib/sponsors';
|
||||||
|
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
|
||||||
|
|
||||||
|
const ROTATE_MS = 30_000;
|
||||||
|
|
||||||
|
interface SponsorSlotProps {
|
||||||
|
slot: Slot;
|
||||||
|
variant?: SponsorCardVariant;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
rotate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SponsorSlot({
|
||||||
|
slot,
|
||||||
|
variant = 'banner',
|
||||||
|
iconOnly,
|
||||||
|
rotate,
|
||||||
|
className,
|
||||||
|
}: SponsorSlotProps) {
|
||||||
|
const { data } = useSponsorsQuery();
|
||||||
|
const [, setDismissTick] = useState(0);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
|
||||||
|
const visible = sponsorsForSlot(data.sponsors, slot).filter(
|
||||||
|
(s) => !isSponsorDismissed(s.id, slot),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rotate || visible.length < 2) return;
|
||||||
|
const timer = window.setInterval(() => setIndex((i) => i + 1), ROTATE_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [rotate, visible.length]);
|
||||||
|
|
||||||
|
if (visible.length === 0) return null;
|
||||||
|
const sponsor = visible[(rotate ? index : 0) % visible.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<SponsorCard
|
||||||
|
sponsor={sponsor}
|
||||||
|
variant={variant}
|
||||||
|
iconOnly={iconOnly}
|
||||||
|
onClose={() => {
|
||||||
|
dismissSponsor(sponsor.id, slot);
|
||||||
|
setDismissTick((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+2
@@ -15,6 +15,8 @@ interface SubPageData {
|
|||||||
subJsonUrl?: string;
|
subJsonUrl?: string;
|
||||||
subClashUrl?: string;
|
subClashUrl?: string;
|
||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
|
subSupportUrl?: string;
|
||||||
|
subUpdates?: number;
|
||||||
links?: string[];
|
links?: string[];
|
||||||
emails?: string[];
|
emails?: string[];
|
||||||
datepicker?: 'gregorian' | 'jalalian';
|
datepicker?: 'gregorian' | 'jalalian';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"discordMemory": 0,
|
"discordMemory": 0,
|
||||||
"discordRunTime": "",
|
"discordRunTime": "",
|
||||||
"expireDiff": 0,
|
"expireDiff": 0,
|
||||||
|
"externalSubUserAgent": "",
|
||||||
"externalTrafficInformEnable": false,
|
"externalTrafficInformEnable": false,
|
||||||
"externalTrafficInformURI": "",
|
"externalTrafficInformURI": "",
|
||||||
"happLinkEnable": false,
|
"happLinkEnable": false,
|
||||||
@@ -80,6 +81,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subHappExcludeApns": false,
|
"subHappExcludeApns": false,
|
||||||
"subHappExcludeRoutes": "",
|
"subHappExcludeRoutes": "",
|
||||||
"subHappFallbackUrl": "",
|
"subHappFallbackUrl": "",
|
||||||
|
"subHappLocalProxyAuth": "",
|
||||||
"subHappNewUrl": "",
|
"subHappNewUrl": "",
|
||||||
"subHappNoLimit": false,
|
"subHappNoLimit": false,
|
||||||
"subHappNotificationExpire": false,
|
"subHappNotificationExpire": false,
|
||||||
@@ -96,8 +98,36 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subHappTunMode": "",
|
"subHappTunMode": "",
|
||||||
"subHappTunType": "",
|
"subHappTunType": "",
|
||||||
"subHideSettings": false,
|
"subHideSettings": false,
|
||||||
|
"subIncyAnnounceUrl": "",
|
||||||
|
"subIncyAppAutoDetect": false,
|
||||||
|
"subIncyBannerBgColor": "",
|
||||||
|
"subIncyBannerButtonColor": "",
|
||||||
|
"subIncyBannerButtonText": "",
|
||||||
|
"subIncyBannerButtonUrl": "",
|
||||||
|
"subIncyBannerText": "",
|
||||||
"subIncyEnableRouting": false,
|
"subIncyEnableRouting": false,
|
||||||
|
"subIncyFragmentInterval": "",
|
||||||
|
"subIncyFragmentLength": "",
|
||||||
|
"subIncyFragmentPackets": "",
|
||||||
|
"subIncyFragmentationEnable": "",
|
||||||
|
"subIncyHideCheck": "",
|
||||||
|
"subIncyHideUrl": "",
|
||||||
|
"subIncyNoLimitEnabled": "",
|
||||||
|
"subIncyNoisesDelay": "",
|
||||||
|
"subIncyNoisesEnable": "",
|
||||||
|
"subIncyNoisesPacket": "",
|
||||||
|
"subIncyNoisesType": "",
|
||||||
|
"subIncyPerAppEnable": "",
|
||||||
|
"subIncyPerAppList": "",
|
||||||
|
"subIncyPerAppMode": "",
|
||||||
|
"subIncyPremiumUrl": "",
|
||||||
|
"subIncyProfileDescription": "",
|
||||||
|
"subIncyResolveDnsDomain": "",
|
||||||
|
"subIncyResolveDnsIp": "",
|
||||||
|
"subIncyResolveEnable": "",
|
||||||
"subIncyRoutingRules": "",
|
"subIncyRoutingRules": "",
|
||||||
|
"subIncySortOrder": "",
|
||||||
|
"subIncySupportEmail": "",
|
||||||
"subInfoNodeEnable": false,
|
"subInfoNodeEnable": false,
|
||||||
"subJsonAlwaysArray": false,
|
"subJsonAlwaysArray": false,
|
||||||
"subJsonAutoDetect": false,
|
"subJsonAutoDetect": false,
|
||||||
@@ -115,6 +145,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
|
"subProfileMode": "",
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
"subShowIdentityOnAllLinks": false,
|
"subShowIdentityOnAllLinks": false,
|
||||||
@@ -161,6 +192,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"discordMemory": 0,
|
"discordMemory": 0,
|
||||||
"discordRunTime": "",
|
"discordRunTime": "",
|
||||||
"expireDiff": 0,
|
"expireDiff": 0,
|
||||||
|
"externalSubUserAgent": "",
|
||||||
"externalTrafficInformEnable": false,
|
"externalTrafficInformEnable": false,
|
||||||
"externalTrafficInformURI": "",
|
"externalTrafficInformURI": "",
|
||||||
"happLinkEnable": false,
|
"happLinkEnable": false,
|
||||||
@@ -236,6 +268,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subHappExcludeApns": false,
|
"subHappExcludeApns": false,
|
||||||
"subHappExcludeRoutes": "",
|
"subHappExcludeRoutes": "",
|
||||||
"subHappFallbackUrl": "",
|
"subHappFallbackUrl": "",
|
||||||
|
"subHappLocalProxyAuth": "",
|
||||||
"subHappNewUrl": "",
|
"subHappNewUrl": "",
|
||||||
"subHappNoLimit": false,
|
"subHappNoLimit": false,
|
||||||
"subHappNotificationExpire": false,
|
"subHappNotificationExpire": false,
|
||||||
@@ -252,8 +285,36 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subHappTunMode": "",
|
"subHappTunMode": "",
|
||||||
"subHappTunType": "",
|
"subHappTunType": "",
|
||||||
"subHideSettings": false,
|
"subHideSettings": false,
|
||||||
|
"subIncyAnnounceUrl": "",
|
||||||
|
"subIncyAppAutoDetect": false,
|
||||||
|
"subIncyBannerBgColor": "",
|
||||||
|
"subIncyBannerButtonColor": "",
|
||||||
|
"subIncyBannerButtonText": "",
|
||||||
|
"subIncyBannerButtonUrl": "",
|
||||||
|
"subIncyBannerText": "",
|
||||||
"subIncyEnableRouting": false,
|
"subIncyEnableRouting": false,
|
||||||
|
"subIncyFragmentInterval": "",
|
||||||
|
"subIncyFragmentLength": "",
|
||||||
|
"subIncyFragmentPackets": "",
|
||||||
|
"subIncyFragmentationEnable": "",
|
||||||
|
"subIncyHideCheck": "",
|
||||||
|
"subIncyHideUrl": "",
|
||||||
|
"subIncyNoLimitEnabled": "",
|
||||||
|
"subIncyNoisesDelay": "",
|
||||||
|
"subIncyNoisesEnable": "",
|
||||||
|
"subIncyNoisesPacket": "",
|
||||||
|
"subIncyNoisesType": "",
|
||||||
|
"subIncyPerAppEnable": "",
|
||||||
|
"subIncyPerAppList": "",
|
||||||
|
"subIncyPerAppMode": "",
|
||||||
|
"subIncyPremiumUrl": "",
|
||||||
|
"subIncyProfileDescription": "",
|
||||||
|
"subIncyResolveDnsDomain": "",
|
||||||
|
"subIncyResolveDnsIp": "",
|
||||||
|
"subIncyResolveEnable": "",
|
||||||
"subIncyRoutingRules": "",
|
"subIncyRoutingRules": "",
|
||||||
|
"subIncySortOrder": "",
|
||||||
|
"subIncySupportEmail": "",
|
||||||
"subInfoNodeEnable": false,
|
"subInfoNodeEnable": false,
|
||||||
"subJsonAlwaysArray": false,
|
"subJsonAlwaysArray": false,
|
||||||
"subJsonAutoDetect": false,
|
"subJsonAutoDetect": false,
|
||||||
@@ -271,6 +332,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
|
"subProfileMode": "",
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
"subShowIdentityOnAllLinks": false,
|
"subShowIdentityOnAllLinks": false,
|
||||||
@@ -367,6 +429,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"reset": 0,
|
"reset": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"reverse": null,
|
"reverse": null,
|
||||||
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||||
"security": "",
|
"security": "",
|
||||||
@@ -406,6 +469,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"reset": 0,
|
"reset": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "abcd1234",
|
"subId": "abcd1234",
|
||||||
"totalGB": 53687091200,
|
"totalGB": 53687091200,
|
||||||
"traffic": null,
|
"traffic": null,
|
||||||
@@ -455,6 +519,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"reset": 0,
|
"reset": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"reverse": null,
|
"reverse": null,
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"security": "",
|
"security": "",
|
||||||
@@ -466,6 +531,25 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"updatedAt": 0,
|
"updatedAt": 0,
|
||||||
"uuid": ""
|
"uuid": ""
|
||||||
},
|
},
|
||||||
|
"ClientRenewalPreview": {
|
||||||
|
"canRenew": true,
|
||||||
|
"delayedStart": false,
|
||||||
|
"nextExpiry": "2030-02-01T00:00:00Z",
|
||||||
|
"renewAt": "2030-01-01T00:00:00Z",
|
||||||
|
"renewals": 1,
|
||||||
|
"suggestedExpiry": "2030-01-01T00:00:00Z",
|
||||||
|
"suggestedExpiryTime": 1893456000000,
|
||||||
|
"timeZone": "UTC",
|
||||||
|
"validThrough": "2029-12-31T23:59:59Z"
|
||||||
|
},
|
||||||
|
"ClientRenewalPreviewRequest": {
|
||||||
|
"expiryTime": 1893456000000,
|
||||||
|
"reset": 0,
|
||||||
|
"resetCount": 0,
|
||||||
|
"resetDay": 1,
|
||||||
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0
|
||||||
|
},
|
||||||
"ClientReverse": {
|
"ClientReverse": {
|
||||||
"tag": ""
|
"tag": ""
|
||||||
},
|
},
|
||||||
@@ -485,6 +569,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"reset": 0,
|
"reset": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "abcd1234",
|
"subId": "abcd1234",
|
||||||
"totalGB": 53687091200,
|
"totalGB": 53687091200,
|
||||||
"traffic": null,
|
"traffic": null,
|
||||||
@@ -503,6 +588,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -589,6 +675,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
@@ -634,6 +721,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"alpn": [
|
"alpn": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
"echConfigList": "",
|
"echConfigList": "",
|
||||||
"excludeFromSubTypes": [
|
"excludeFromSubTypes": [
|
||||||
""
|
""
|
||||||
@@ -698,6 +786,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"resetCount": 0,
|
"resetCount": 0,
|
||||||
"resetDay": 0,
|
"resetDay": 0,
|
||||||
"resetMax": 0,
|
"resetMax": 0,
|
||||||
|
"resetWeekday": 0,
|
||||||
"subId": "i7tvdpeffi0hvvf1",
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
"total": 10737418240,
|
"total": 10737418240,
|
||||||
"up": 1048576,
|
"up": 1048576,
|
||||||
@@ -707,6 +796,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"disableFlow": false,
|
"disableFlow": false,
|
||||||
"down": 0,
|
"down": 0,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
|
"excludeFromSub": false,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"fallbackParent": null,
|
"fallbackParent": null,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -1015,6 +1105,39 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"key": "",
|
"key": "",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
"Sponsor": {
|
||||||
|
"enable": true,
|
||||||
|
"from": "2026-10-01T00:00:00Z",
|
||||||
|
"id": "acme-2026-10",
|
||||||
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
"name": "Acme VPS",
|
||||||
|
"slots": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"text": {},
|
||||||
|
"title": {},
|
||||||
|
"until": "2026-11-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
"SponsorList": {
|
||||||
|
"contact": "https://t.me/example",
|
||||||
|
"sponsors": [
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"from": "2026-10-01T00:00:00Z",
|
||||||
|
"id": "acme-2026-10",
|
||||||
|
"link": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"logo": "/sponsors/logo/acme.png",
|
||||||
|
"name": "Acme VPS",
|
||||||
|
"slots": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"text": {},
|
||||||
|
"title": {},
|
||||||
|
"until": "2026-11-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"SubBalancer": {
|
"SubBalancer": {
|
||||||
"createdAt": 1710000000000,
|
"createdAt": 1710000000000,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"externalSubUserAgent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"externalTrafficInformEnable": {
|
"externalTrafficInformEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -262,6 +265,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHappFallbackUrl": {
|
"subHappFallbackUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subHappLocalProxyAuth": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subHappNewUrl": {
|
"subHappNewUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -310,12 +316,97 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyAnnounceUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyAppAutoDetect": {
|
||||||
|
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subIncyBannerBgColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyEnableRouting": {
|
"subIncyEnableRouting": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyFragmentInterval": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentLength": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentPackets": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentationEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideCheck": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoLimitEnabled": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesDelay": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesPacket": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppList": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPremiumUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyProfileDescription": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsDomain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsIp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyRoutingRules": {
|
"subIncyRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subIncySortOrder": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncySupportEmail": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subInfoNodeEnable": {
|
"subInfoNodeEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -369,6 +460,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -490,6 +584,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"discordMemory",
|
"discordMemory",
|
||||||
"discordRunTime",
|
"discordRunTime",
|
||||||
"expireDiff",
|
"expireDiff",
|
||||||
|
"externalSubUserAgent",
|
||||||
"externalTrafficInformEnable",
|
"externalTrafficInformEnable",
|
||||||
"externalTrafficInformURI",
|
"externalTrafficInformURI",
|
||||||
"happLinkEnable",
|
"happLinkEnable",
|
||||||
@@ -557,6 +652,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHappExcludeApns",
|
"subHappExcludeApns",
|
||||||
"subHappExcludeRoutes",
|
"subHappExcludeRoutes",
|
||||||
"subHappFallbackUrl",
|
"subHappFallbackUrl",
|
||||||
|
"subHappLocalProxyAuth",
|
||||||
"subHappNewUrl",
|
"subHappNewUrl",
|
||||||
"subHappNoLimit",
|
"subHappNoLimit",
|
||||||
"subHappNotificationExpire",
|
"subHappNotificationExpire",
|
||||||
@@ -573,8 +669,36 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subIncyAnnounceUrl",
|
||||||
|
"subIncyAppAutoDetect",
|
||||||
|
"subIncyBannerBgColor",
|
||||||
|
"subIncyBannerButtonColor",
|
||||||
|
"subIncyBannerButtonText",
|
||||||
|
"subIncyBannerButtonUrl",
|
||||||
|
"subIncyBannerText",
|
||||||
"subIncyEnableRouting",
|
"subIncyEnableRouting",
|
||||||
|
"subIncyFragmentInterval",
|
||||||
|
"subIncyFragmentLength",
|
||||||
|
"subIncyFragmentPackets",
|
||||||
|
"subIncyFragmentationEnable",
|
||||||
|
"subIncyHideCheck",
|
||||||
|
"subIncyHideUrl",
|
||||||
|
"subIncyNoLimitEnabled",
|
||||||
|
"subIncyNoisesDelay",
|
||||||
|
"subIncyNoisesEnable",
|
||||||
|
"subIncyNoisesPacket",
|
||||||
|
"subIncyNoisesType",
|
||||||
|
"subIncyPerAppEnable",
|
||||||
|
"subIncyPerAppList",
|
||||||
|
"subIncyPerAppMode",
|
||||||
|
"subIncyPremiumUrl",
|
||||||
|
"subIncyProfileDescription",
|
||||||
|
"subIncyResolveDnsDomain",
|
||||||
|
"subIncyResolveDnsIp",
|
||||||
|
"subIncyResolveEnable",
|
||||||
"subIncyRoutingRules",
|
"subIncyRoutingRules",
|
||||||
|
"subIncySortOrder",
|
||||||
|
"subIncySupportEmail",
|
||||||
"subInfoNodeEnable",
|
"subInfoNodeEnable",
|
||||||
"subJsonAlwaysArray",
|
"subJsonAlwaysArray",
|
||||||
"subJsonAutoDetect",
|
"subJsonAutoDetect",
|
||||||
@@ -592,6 +716,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -670,6 +795,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"externalSubUserAgent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"externalTrafficInformEnable": {
|
"externalTrafficInformEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -913,6 +1041,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHappFallbackUrl": {
|
"subHappFallbackUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subHappLocalProxyAuth": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subHappNewUrl": {
|
"subHappNewUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -961,12 +1092,97 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyAnnounceUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyAppAutoDetect": {
|
||||||
|
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subIncyBannerBgColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerButtonUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyBannerText": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyEnableRouting": {
|
"subIncyEnableRouting": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subIncyFragmentInterval": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentLength": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentPackets": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyFragmentationEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideCheck": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyHideUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoLimitEnabled": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesDelay": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesPacket": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyNoisesType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppList": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPerAppMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyPremiumUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyProfileDescription": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsDomain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveDnsIp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncyResolveEnable": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyRoutingRules": {
|
"subIncyRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subIncySortOrder": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subIncySupportEmail": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subInfoNodeEnable": {
|
"subInfoNodeEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -1020,6 +1236,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1141,6 +1360,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"discordMemory",
|
"discordMemory",
|
||||||
"discordRunTime",
|
"discordRunTime",
|
||||||
"expireDiff",
|
"expireDiff",
|
||||||
|
"externalSubUserAgent",
|
||||||
"externalTrafficInformEnable",
|
"externalTrafficInformEnable",
|
||||||
"externalTrafficInformURI",
|
"externalTrafficInformURI",
|
||||||
"happLinkEnable",
|
"happLinkEnable",
|
||||||
@@ -1216,6 +1436,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHappExcludeApns",
|
"subHappExcludeApns",
|
||||||
"subHappExcludeRoutes",
|
"subHappExcludeRoutes",
|
||||||
"subHappFallbackUrl",
|
"subHappFallbackUrl",
|
||||||
|
"subHappLocalProxyAuth",
|
||||||
"subHappNewUrl",
|
"subHappNewUrl",
|
||||||
"subHappNoLimit",
|
"subHappNoLimit",
|
||||||
"subHappNotificationExpire",
|
"subHappNotificationExpire",
|
||||||
@@ -1232,8 +1453,36 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subIncyAnnounceUrl",
|
||||||
|
"subIncyAppAutoDetect",
|
||||||
|
"subIncyBannerBgColor",
|
||||||
|
"subIncyBannerButtonColor",
|
||||||
|
"subIncyBannerButtonText",
|
||||||
|
"subIncyBannerButtonUrl",
|
||||||
|
"subIncyBannerText",
|
||||||
"subIncyEnableRouting",
|
"subIncyEnableRouting",
|
||||||
|
"subIncyFragmentInterval",
|
||||||
|
"subIncyFragmentLength",
|
||||||
|
"subIncyFragmentPackets",
|
||||||
|
"subIncyFragmentationEnable",
|
||||||
|
"subIncyHideCheck",
|
||||||
|
"subIncyHideUrl",
|
||||||
|
"subIncyNoLimitEnabled",
|
||||||
|
"subIncyNoisesDelay",
|
||||||
|
"subIncyNoisesEnable",
|
||||||
|
"subIncyNoisesPacket",
|
||||||
|
"subIncyNoisesType",
|
||||||
|
"subIncyPerAppEnable",
|
||||||
|
"subIncyPerAppList",
|
||||||
|
"subIncyPerAppMode",
|
||||||
|
"subIncyPremiumUrl",
|
||||||
|
"subIncyProfileDescription",
|
||||||
|
"subIncyResolveDnsDomain",
|
||||||
|
"subIncyResolveDnsIp",
|
||||||
|
"subIncyResolveEnable",
|
||||||
"subIncyRoutingRules",
|
"subIncyRoutingRules",
|
||||||
|
"subIncySortOrder",
|
||||||
|
"subIncySupportEmail",
|
||||||
"subInfoNodeEnable",
|
"subInfoNodeEnable",
|
||||||
"subJsonAlwaysArray",
|
"subJsonAlwaysArray",
|
||||||
"subJsonAutoDetect",
|
"subJsonAutoDetect",
|
||||||
@@ -1251,6 +1500,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -1489,13 +1739,17 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetDay": {
|
"resetDay": {
|
||||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetMax": {
|
"resetMax": {
|
||||||
"description": "Max auto-renew count, 0 = unlimited",
|
"description": "Max auto-renew count, 0 = unlimited",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"reverse": {
|
"reverse": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -1558,6 +1812,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"security",
|
"security",
|
||||||
"subId",
|
"subId",
|
||||||
"tgId",
|
"tgId",
|
||||||
@@ -1709,6 +1964,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"resetMax": {
|
"resetMax": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"reverse": {},
|
"reverse": {},
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1764,6 +2022,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"reverse",
|
"reverse",
|
||||||
"secret",
|
"secret",
|
||||||
"security",
|
"security",
|
||||||
@@ -1777,6 +2036,97 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ClientRenewalPreview": {
|
||||||
|
"properties": {
|
||||||
|
"canRenew": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"delayedStart": {
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nextExpiry": {
|
||||||
|
"example": "2030-02-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"renewAt": {
|
||||||
|
"example": "2030-01-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"renewals": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"suggestedExpiry": {
|
||||||
|
"example": "2030-01-01T00:00:00Z",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"suggestedExpiryTime": {
|
||||||
|
"example": 1893456000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"example": "UTC",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"validThrough": {
|
||||||
|
"example": "2029-12-31T23:59:59Z",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"canRenew",
|
||||||
|
"delayedStart",
|
||||||
|
"nextExpiry",
|
||||||
|
"renewAt",
|
||||||
|
"renewals",
|
||||||
|
"suggestedExpiry",
|
||||||
|
"suggestedExpiryTime",
|
||||||
|
"timeZone",
|
||||||
|
"validThrough"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ClientRenewalPreviewRequest": {
|
||||||
|
"properties": {
|
||||||
|
"expiryTime": {
|
||||||
|
"example": 1893456000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"reset": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetCount": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetDay": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetMax": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expiryTime",
|
||||||
|
"reset",
|
||||||
|
"resetCount",
|
||||||
|
"resetDay",
|
||||||
|
"resetMax",
|
||||||
|
"resetWeekday"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ClientReverse": {
|
"ClientReverse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"tag": {
|
"tag": {
|
||||||
@@ -1847,6 +2197,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"subId": {
|
"subId": {
|
||||||
"example": "abcd1234",
|
"example": "abcd1234",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1881,6 +2235,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"reset",
|
"reset",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"subId",
|
"subId",
|
||||||
"totalGB",
|
"totalGB",
|
||||||
"updatedAt"
|
"updatedAt"
|
||||||
@@ -1936,7 +2291,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"resetDay": {
|
"resetDay": {
|
||||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
|
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
|
||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@@ -1945,6 +2300,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 0,
|
"example": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"resetWeekday": {
|
||||||
|
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||||
|
"example": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"subId": {
|
"subId": {
|
||||||
"example": "i7tvdpeffi0hvvf1",
|
"example": "i7tvdpeffi0hvvf1",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1977,6 +2337,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"resetCount",
|
"resetCount",
|
||||||
"resetDay",
|
"resetDay",
|
||||||
"resetMax",
|
"resetMax",
|
||||||
|
"resetWeekday",
|
||||||
"subId",
|
"subId",
|
||||||
"total",
|
"total",
|
||||||
"up",
|
"up",
|
||||||
@@ -2267,6 +2628,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"cipherSuites": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2400,6 +2764,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"address",
|
"address",
|
||||||
"allowInsecure",
|
"allowInsecure",
|
||||||
"alpn",
|
"alpn",
|
||||||
|
"cipherSuites",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"echConfigList",
|
"echConfigList",
|
||||||
"excludeFromSubTypes",
|
"excludeFromSubTypes",
|
||||||
@@ -2444,6 +2809,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"cipherSuites": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"echConfigList": {
|
"echConfigList": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2570,6 +2938,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"required": [
|
"required": [
|
||||||
"allowInsecure",
|
"allowInsecure",
|
||||||
"alpn",
|
"alpn",
|
||||||
|
"cipherSuites",
|
||||||
"echConfigList",
|
"echConfigList",
|
||||||
"excludeFromSubTypes",
|
"excludeFromSubTypes",
|
||||||
"finalMask",
|
"finalMask",
|
||||||
@@ -2659,6 +3028,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"excludeFromSub": {
|
||||||
|
"description": "Whether to omit this inbound from subscription output while keeping it operational",
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"expiryTime": {
|
"expiryTime": {
|
||||||
"description": "Expiration timestamp",
|
"description": "Expiration timestamp",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -2782,6 +3156,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"disableFlow",
|
"disableFlow",
|
||||||
"down",
|
"down",
|
||||||
"enable",
|
"enable",
|
||||||
|
"excludeFromSub",
|
||||||
"expiryTime",
|
"expiryTime",
|
||||||
"id",
|
"id",
|
||||||
"lastTrafficResetTime",
|
"lastTrafficResetTime",
|
||||||
@@ -4093,6 +4468,90 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Sponsor": {
|
||||||
|
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"example": true,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"example": "2026-10-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": "acme-2026-10",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"example": "https://acme.example/?utm_source=3x-ui",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"example": "/sponsors/logo/acme.png",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"example": "Acme VPS",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"until": {
|
||||||
|
"example": "2026-11-01T00:00:00Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"link",
|
||||||
|
"name",
|
||||||
|
"slots",
|
||||||
|
"text",
|
||||||
|
"title",
|
||||||
|
"until"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SponsorList": {
|
||||||
|
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||||
|
"properties": {
|
||||||
|
"contact": {
|
||||||
|
"example": "https://t.me/example",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sponsors": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Sponsor"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"sponsors"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubBalancer": {
|
"SubBalancer": {
|
||||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type GeoKind = string;
|
|||||||
export type OnlineAPISupport = number;
|
export type OnlineAPISupport = number;
|
||||||
export type ProcessState = string;
|
export type ProcessState = string;
|
||||||
export type Protocol = string;
|
export type Protocol = string;
|
||||||
|
export type addrFamily = number;
|
||||||
export type staticEgressResolver = string;
|
export type staticEgressResolver = string;
|
||||||
export type trafficLocalApplyAction = number;
|
export type trafficLocalApplyAction = number;
|
||||||
export type transportBits = number;
|
export type transportBits = number;
|
||||||
@@ -20,6 +21,7 @@ export interface AllSetting {
|
|||||||
discordMemory: number;
|
discordMemory: number;
|
||||||
discordRunTime: string;
|
discordRunTime: string;
|
||||||
expireDiff: number;
|
expireDiff: number;
|
||||||
|
externalSubUserAgent: string;
|
||||||
externalTrafficInformEnable: boolean;
|
externalTrafficInformEnable: boolean;
|
||||||
externalTrafficInformURI: string;
|
externalTrafficInformURI: string;
|
||||||
happLinkEnable: boolean;
|
happLinkEnable: boolean;
|
||||||
@@ -87,6 +89,7 @@ export interface AllSetting {
|
|||||||
subHappExcludeApns: boolean;
|
subHappExcludeApns: boolean;
|
||||||
subHappExcludeRoutes: string;
|
subHappExcludeRoutes: string;
|
||||||
subHappFallbackUrl: string;
|
subHappFallbackUrl: string;
|
||||||
|
subHappLocalProxyAuth: string;
|
||||||
subHappNewUrl: string;
|
subHappNewUrl: string;
|
||||||
subHappNoLimit: boolean;
|
subHappNoLimit: boolean;
|
||||||
subHappNotificationExpire: boolean;
|
subHappNotificationExpire: boolean;
|
||||||
@@ -103,8 +106,36 @@ export interface AllSetting {
|
|||||||
subHappTunMode: string;
|
subHappTunMode: string;
|
||||||
subHappTunType: string;
|
subHappTunType: string;
|
||||||
subHideSettings: boolean;
|
subHideSettings: boolean;
|
||||||
|
subIncyAnnounceUrl: string;
|
||||||
|
subIncyAppAutoDetect: boolean;
|
||||||
|
subIncyBannerBgColor: string;
|
||||||
|
subIncyBannerButtonColor: string;
|
||||||
|
subIncyBannerButtonText: string;
|
||||||
|
subIncyBannerButtonUrl: string;
|
||||||
|
subIncyBannerText: string;
|
||||||
subIncyEnableRouting: boolean;
|
subIncyEnableRouting: boolean;
|
||||||
|
subIncyFragmentInterval: string;
|
||||||
|
subIncyFragmentLength: string;
|
||||||
|
subIncyFragmentPackets: string;
|
||||||
|
subIncyFragmentationEnable: string;
|
||||||
|
subIncyHideCheck: string;
|
||||||
|
subIncyHideUrl: string;
|
||||||
|
subIncyNoLimitEnabled: string;
|
||||||
|
subIncyNoisesDelay: string;
|
||||||
|
subIncyNoisesEnable: string;
|
||||||
|
subIncyNoisesPacket: string;
|
||||||
|
subIncyNoisesType: string;
|
||||||
|
subIncyPerAppEnable: string;
|
||||||
|
subIncyPerAppList: string;
|
||||||
|
subIncyPerAppMode: string;
|
||||||
|
subIncyPremiumUrl: string;
|
||||||
|
subIncyProfileDescription: string;
|
||||||
|
subIncyResolveDnsDomain: string;
|
||||||
|
subIncyResolveDnsIp: string;
|
||||||
|
subIncyResolveEnable: string;
|
||||||
subIncyRoutingRules: string;
|
subIncyRoutingRules: string;
|
||||||
|
subIncySortOrder: string;
|
||||||
|
subIncySupportEmail: string;
|
||||||
subInfoNodeEnable: boolean;
|
subInfoNodeEnable: boolean;
|
||||||
subJsonAlwaysArray: boolean;
|
subJsonAlwaysArray: boolean;
|
||||||
subJsonAutoDetect: boolean;
|
subJsonAutoDetect: boolean;
|
||||||
@@ -122,6 +153,7 @@ export interface AllSetting {
|
|||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
subPort: number;
|
subPort: number;
|
||||||
|
subProfileMode: string;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
subShowIdentityOnAllLinks: boolean;
|
subShowIdentityOnAllLinks: boolean;
|
||||||
@@ -169,6 +201,7 @@ export interface AllSettingView {
|
|||||||
discordMemory: number;
|
discordMemory: number;
|
||||||
discordRunTime: string;
|
discordRunTime: string;
|
||||||
expireDiff: number;
|
expireDiff: number;
|
||||||
|
externalSubUserAgent: string;
|
||||||
externalTrafficInformEnable: boolean;
|
externalTrafficInformEnable: boolean;
|
||||||
externalTrafficInformURI: string;
|
externalTrafficInformURI: string;
|
||||||
happLinkEnable: boolean;
|
happLinkEnable: boolean;
|
||||||
@@ -244,6 +277,7 @@ export interface AllSettingView {
|
|||||||
subHappExcludeApns: boolean;
|
subHappExcludeApns: boolean;
|
||||||
subHappExcludeRoutes: string;
|
subHappExcludeRoutes: string;
|
||||||
subHappFallbackUrl: string;
|
subHappFallbackUrl: string;
|
||||||
|
subHappLocalProxyAuth: string;
|
||||||
subHappNewUrl: string;
|
subHappNewUrl: string;
|
||||||
subHappNoLimit: boolean;
|
subHappNoLimit: boolean;
|
||||||
subHappNotificationExpire: boolean;
|
subHappNotificationExpire: boolean;
|
||||||
@@ -260,8 +294,36 @@ export interface AllSettingView {
|
|||||||
subHappTunMode: string;
|
subHappTunMode: string;
|
||||||
subHappTunType: string;
|
subHappTunType: string;
|
||||||
subHideSettings: boolean;
|
subHideSettings: boolean;
|
||||||
|
subIncyAnnounceUrl: string;
|
||||||
|
subIncyAppAutoDetect: boolean;
|
||||||
|
subIncyBannerBgColor: string;
|
||||||
|
subIncyBannerButtonColor: string;
|
||||||
|
subIncyBannerButtonText: string;
|
||||||
|
subIncyBannerButtonUrl: string;
|
||||||
|
subIncyBannerText: string;
|
||||||
subIncyEnableRouting: boolean;
|
subIncyEnableRouting: boolean;
|
||||||
|
subIncyFragmentInterval: string;
|
||||||
|
subIncyFragmentLength: string;
|
||||||
|
subIncyFragmentPackets: string;
|
||||||
|
subIncyFragmentationEnable: string;
|
||||||
|
subIncyHideCheck: string;
|
||||||
|
subIncyHideUrl: string;
|
||||||
|
subIncyNoLimitEnabled: string;
|
||||||
|
subIncyNoisesDelay: string;
|
||||||
|
subIncyNoisesEnable: string;
|
||||||
|
subIncyNoisesPacket: string;
|
||||||
|
subIncyNoisesType: string;
|
||||||
|
subIncyPerAppEnable: string;
|
||||||
|
subIncyPerAppList: string;
|
||||||
|
subIncyPerAppMode: string;
|
||||||
|
subIncyPremiumUrl: string;
|
||||||
|
subIncyProfileDescription: string;
|
||||||
|
subIncyResolveDnsDomain: string;
|
||||||
|
subIncyResolveDnsIp: string;
|
||||||
|
subIncyResolveEnable: string;
|
||||||
subIncyRoutingRules: string;
|
subIncyRoutingRules: string;
|
||||||
|
subIncySortOrder: string;
|
||||||
|
subIncySupportEmail: string;
|
||||||
subInfoNodeEnable: boolean;
|
subInfoNodeEnable: boolean;
|
||||||
subJsonAlwaysArray: boolean;
|
subJsonAlwaysArray: boolean;
|
||||||
subJsonAutoDetect: boolean;
|
subJsonAutoDetect: boolean;
|
||||||
@@ -279,6 +341,7 @@ export interface AllSettingView {
|
|||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
subPort: number;
|
subPort: number;
|
||||||
|
subProfileMode: string;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
subShowIdentityOnAllLinks: boolean;
|
subShowIdentityOnAllLinks: boolean;
|
||||||
@@ -362,6 +425,7 @@ export interface Client {
|
|||||||
reset: number;
|
reset: number;
|
||||||
resetDay: number;
|
resetDay: number;
|
||||||
resetMax: number;
|
resetMax: number;
|
||||||
|
resetWeekday: number;
|
||||||
reverse?: ClientReverse | null;
|
reverse?: ClientReverse | null;
|
||||||
secret?: string;
|
secret?: string;
|
||||||
security: string;
|
security: string;
|
||||||
@@ -413,6 +477,7 @@ export interface ClientRecord {
|
|||||||
reset: number;
|
reset: number;
|
||||||
resetDay: number;
|
resetDay: number;
|
||||||
resetMax: number;
|
resetMax: number;
|
||||||
|
resetWeekday: number;
|
||||||
reverse: unknown;
|
reverse: unknown;
|
||||||
secret: string;
|
secret: string;
|
||||||
security: string;
|
security: string;
|
||||||
@@ -425,6 +490,27 @@ export interface ClientRecord {
|
|||||||
uuid: string;
|
uuid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientRenewalPreview {
|
||||||
|
canRenew: boolean;
|
||||||
|
delayedStart: boolean;
|
||||||
|
nextExpiry: string;
|
||||||
|
renewAt: string;
|
||||||
|
renewals: number;
|
||||||
|
suggestedExpiry: string;
|
||||||
|
suggestedExpiryTime: number;
|
||||||
|
timeZone: string;
|
||||||
|
validThrough: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientRenewalPreviewRequest {
|
||||||
|
expiryTime: number;
|
||||||
|
reset: number;
|
||||||
|
resetCount: number;
|
||||||
|
resetDay: number;
|
||||||
|
resetMax: number;
|
||||||
|
resetWeekday: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientReverse {
|
export interface ClientReverse {
|
||||||
tag: string;
|
tag: string;
|
||||||
}
|
}
|
||||||
@@ -442,6 +528,7 @@ export interface ClientSlim {
|
|||||||
reset: number;
|
reset: number;
|
||||||
resetDay: number;
|
resetDay: number;
|
||||||
resetMax: number;
|
resetMax: number;
|
||||||
|
resetWeekday: number;
|
||||||
subId: string;
|
subId: string;
|
||||||
totalGB: number;
|
totalGB: number;
|
||||||
traffic?: ClientTraffic | null;
|
traffic?: ClientTraffic | null;
|
||||||
@@ -461,6 +548,7 @@ export interface ClientTraffic {
|
|||||||
resetCount: number;
|
resetCount: number;
|
||||||
resetDay: number;
|
resetDay: number;
|
||||||
resetMax: number;
|
resetMax: number;
|
||||||
|
resetWeekday: number;
|
||||||
subId: string;
|
subId: string;
|
||||||
total: number;
|
total: number;
|
||||||
up: number;
|
up: number;
|
||||||
@@ -535,6 +623,7 @@ export interface Host {
|
|||||||
address: string;
|
address: string;
|
||||||
allowInsecure: boolean;
|
allowInsecure: boolean;
|
||||||
alpn: string[];
|
alpn: string[];
|
||||||
|
cipherSuites: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
echConfigList: string;
|
echConfigList: string;
|
||||||
excludeFromSubTypes: string[];
|
excludeFromSubTypes: string[];
|
||||||
@@ -571,6 +660,7 @@ export interface Host {
|
|||||||
export interface HostGroup {
|
export interface HostGroup {
|
||||||
allowInsecure: boolean;
|
allowInsecure: boolean;
|
||||||
alpn: string[];
|
alpn: string[];
|
||||||
|
cipherSuites: string;
|
||||||
echConfigList: string;
|
echConfigList: string;
|
||||||
excludeFromSubTypes: string[];
|
excludeFromSubTypes: string[];
|
||||||
finalMask: string;
|
finalMask: string;
|
||||||
@@ -615,6 +705,7 @@ export interface Inbound {
|
|||||||
disableFlow: boolean;
|
disableFlow: boolean;
|
||||||
down: number;
|
down: number;
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
|
excludeFromSub: boolean;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
fallbackParent?: FallbackParentInfo | null;
|
fallbackParent?: FallbackParentInfo | null;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -935,6 +1026,24 @@ export interface Setting {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Sponsor {
|
||||||
|
enable?: boolean | null;
|
||||||
|
from?: string | null;
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
logo?: string;
|
||||||
|
name: string;
|
||||||
|
slots: string[];
|
||||||
|
text: Record<string, string>;
|
||||||
|
title: Record<string, string>;
|
||||||
|
until: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SponsorList {
|
||||||
|
contact?: string;
|
||||||
|
sponsors: Sponsor[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubBalancer {
|
export interface SubBalancer {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export type ProcessState = z.infer<typeof ProcessStateSchema>;
|
|||||||
export const ProtocolSchema = z.string();
|
export const ProtocolSchema = z.string();
|
||||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||||
|
|
||||||
|
export const addrFamilySchema = z.number().int();
|
||||||
|
export type addrFamily = z.infer<typeof addrFamilySchema>;
|
||||||
|
|
||||||
export const staticEgressResolverSchema = z.string();
|
export const staticEgressResolverSchema = z.string();
|
||||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@ export const AllSettingSchema = z.object({
|
|||||||
discordMemory: z.number().int().min(0).max(100),
|
discordMemory: z.number().int().min(0).max(100),
|
||||||
discordRunTime: z.string(),
|
discordRunTime: z.string(),
|
||||||
expireDiff: z.number().int().min(0),
|
expireDiff: z.number().int().min(0),
|
||||||
|
externalSubUserAgent: z.string(),
|
||||||
externalTrafficInformEnable: z.boolean(),
|
externalTrafficInformEnable: z.boolean(),
|
||||||
externalTrafficInformURI: z.string(),
|
externalTrafficInformURI: z.string(),
|
||||||
happLinkEnable: z.boolean(),
|
happLinkEnable: z.boolean(),
|
||||||
@@ -101,6 +105,7 @@ export const AllSettingSchema = z.object({
|
|||||||
subHappExcludeApns: z.boolean(),
|
subHappExcludeApns: z.boolean(),
|
||||||
subHappExcludeRoutes: z.string(),
|
subHappExcludeRoutes: z.string(),
|
||||||
subHappFallbackUrl: z.string(),
|
subHappFallbackUrl: z.string(),
|
||||||
|
subHappLocalProxyAuth: z.string(),
|
||||||
subHappNewUrl: z.string(),
|
subHappNewUrl: z.string(),
|
||||||
subHappNoLimit: z.boolean(),
|
subHappNoLimit: z.boolean(),
|
||||||
subHappNotificationExpire: z.boolean(),
|
subHappNotificationExpire: z.boolean(),
|
||||||
@@ -117,8 +122,36 @@ export const AllSettingSchema = z.object({
|
|||||||
subHappTunMode: z.string(),
|
subHappTunMode: z.string(),
|
||||||
subHappTunType: z.string(),
|
subHappTunType: z.string(),
|
||||||
subHideSettings: z.boolean(),
|
subHideSettings: z.boolean(),
|
||||||
|
subIncyAnnounceUrl: z.string(),
|
||||||
|
subIncyAppAutoDetect: z.boolean(),
|
||||||
|
subIncyBannerBgColor: z.string(),
|
||||||
|
subIncyBannerButtonColor: z.string(),
|
||||||
|
subIncyBannerButtonText: z.string(),
|
||||||
|
subIncyBannerButtonUrl: z.string(),
|
||||||
|
subIncyBannerText: z.string(),
|
||||||
subIncyEnableRouting: z.boolean(),
|
subIncyEnableRouting: z.boolean(),
|
||||||
|
subIncyFragmentInterval: z.string(),
|
||||||
|
subIncyFragmentLength: z.string(),
|
||||||
|
subIncyFragmentPackets: z.string(),
|
||||||
|
subIncyFragmentationEnable: z.string(),
|
||||||
|
subIncyHideCheck: z.string(),
|
||||||
|
subIncyHideUrl: z.string(),
|
||||||
|
subIncyNoLimitEnabled: z.string(),
|
||||||
|
subIncyNoisesDelay: z.string(),
|
||||||
|
subIncyNoisesEnable: z.string(),
|
||||||
|
subIncyNoisesPacket: z.string(),
|
||||||
|
subIncyNoisesType: z.string(),
|
||||||
|
subIncyPerAppEnable: z.string(),
|
||||||
|
subIncyPerAppList: z.string(),
|
||||||
|
subIncyPerAppMode: z.string(),
|
||||||
|
subIncyPremiumUrl: z.string(),
|
||||||
|
subIncyProfileDescription: z.string(),
|
||||||
|
subIncyResolveDnsDomain: z.string(),
|
||||||
|
subIncyResolveDnsIp: z.string(),
|
||||||
|
subIncyResolveEnable: z.string(),
|
||||||
subIncyRoutingRules: z.string(),
|
subIncyRoutingRules: z.string(),
|
||||||
|
subIncySortOrder: z.string(),
|
||||||
|
subIncySupportEmail: z.string(),
|
||||||
subInfoNodeEnable: z.boolean(),
|
subInfoNodeEnable: z.boolean(),
|
||||||
subJsonAlwaysArray: z.boolean(),
|
subJsonAlwaysArray: z.boolean(),
|
||||||
subJsonAutoDetect: z.boolean(),
|
subJsonAutoDetect: z.boolean(),
|
||||||
@@ -136,6 +169,7 @@ export const AllSettingSchema = z.object({
|
|||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(1).max(65535),
|
||||||
|
subProfileMode: z.string(),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
@@ -184,6 +218,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
discordMemory: z.number().int().min(0).max(100),
|
discordMemory: z.number().int().min(0).max(100),
|
||||||
discordRunTime: z.string(),
|
discordRunTime: z.string(),
|
||||||
expireDiff: z.number().int().min(0),
|
expireDiff: z.number().int().min(0),
|
||||||
|
externalSubUserAgent: z.string(),
|
||||||
externalTrafficInformEnable: z.boolean(),
|
externalTrafficInformEnable: z.boolean(),
|
||||||
externalTrafficInformURI: z.string(),
|
externalTrafficInformURI: z.string(),
|
||||||
happLinkEnable: z.boolean(),
|
happLinkEnable: z.boolean(),
|
||||||
@@ -259,6 +294,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subHappExcludeApns: z.boolean(),
|
subHappExcludeApns: z.boolean(),
|
||||||
subHappExcludeRoutes: z.string(),
|
subHappExcludeRoutes: z.string(),
|
||||||
subHappFallbackUrl: z.string(),
|
subHappFallbackUrl: z.string(),
|
||||||
|
subHappLocalProxyAuth: z.string(),
|
||||||
subHappNewUrl: z.string(),
|
subHappNewUrl: z.string(),
|
||||||
subHappNoLimit: z.boolean(),
|
subHappNoLimit: z.boolean(),
|
||||||
subHappNotificationExpire: z.boolean(),
|
subHappNotificationExpire: z.boolean(),
|
||||||
@@ -275,8 +311,36 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subHappTunMode: z.string(),
|
subHappTunMode: z.string(),
|
||||||
subHappTunType: z.string(),
|
subHappTunType: z.string(),
|
||||||
subHideSettings: z.boolean(),
|
subHideSettings: z.boolean(),
|
||||||
|
subIncyAnnounceUrl: z.string(),
|
||||||
|
subIncyAppAutoDetect: z.boolean(),
|
||||||
|
subIncyBannerBgColor: z.string(),
|
||||||
|
subIncyBannerButtonColor: z.string(),
|
||||||
|
subIncyBannerButtonText: z.string(),
|
||||||
|
subIncyBannerButtonUrl: z.string(),
|
||||||
|
subIncyBannerText: z.string(),
|
||||||
subIncyEnableRouting: z.boolean(),
|
subIncyEnableRouting: z.boolean(),
|
||||||
|
subIncyFragmentInterval: z.string(),
|
||||||
|
subIncyFragmentLength: z.string(),
|
||||||
|
subIncyFragmentPackets: z.string(),
|
||||||
|
subIncyFragmentationEnable: z.string(),
|
||||||
|
subIncyHideCheck: z.string(),
|
||||||
|
subIncyHideUrl: z.string(),
|
||||||
|
subIncyNoLimitEnabled: z.string(),
|
||||||
|
subIncyNoisesDelay: z.string(),
|
||||||
|
subIncyNoisesEnable: z.string(),
|
||||||
|
subIncyNoisesPacket: z.string(),
|
||||||
|
subIncyNoisesType: z.string(),
|
||||||
|
subIncyPerAppEnable: z.string(),
|
||||||
|
subIncyPerAppList: z.string(),
|
||||||
|
subIncyPerAppMode: z.string(),
|
||||||
|
subIncyPremiumUrl: z.string(),
|
||||||
|
subIncyProfileDescription: z.string(),
|
||||||
|
subIncyResolveDnsDomain: z.string(),
|
||||||
|
subIncyResolveDnsIp: z.string(),
|
||||||
|
subIncyResolveEnable: z.string(),
|
||||||
subIncyRoutingRules: z.string(),
|
subIncyRoutingRules: z.string(),
|
||||||
|
subIncySortOrder: z.string(),
|
||||||
|
subIncySupportEmail: z.string(),
|
||||||
subInfoNodeEnable: z.boolean(),
|
subInfoNodeEnable: z.boolean(),
|
||||||
subJsonAlwaysArray: z.boolean(),
|
subJsonAlwaysArray: z.boolean(),
|
||||||
subJsonAutoDetect: z.boolean(),
|
subJsonAutoDetect: z.boolean(),
|
||||||
@@ -294,6 +358,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(1).max(65535),
|
||||||
|
subProfileMode: z.string(),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
@@ -381,6 +446,7 @@ export const ClientSchema = z.object({
|
|||||||
reset: z.number().int(),
|
reset: z.number().int(),
|
||||||
resetDay: z.number().int(),
|
resetDay: z.number().int(),
|
||||||
resetMax: z.number().int(),
|
resetMax: z.number().int(),
|
||||||
|
resetWeekday: z.number().int(),
|
||||||
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
||||||
secret: z.string().optional(),
|
secret: z.string().optional(),
|
||||||
security: z.string(),
|
security: z.string(),
|
||||||
@@ -435,6 +501,7 @@ export const ClientRecordSchema = z.object({
|
|||||||
reset: z.number().int(),
|
reset: z.number().int(),
|
||||||
resetDay: z.number().int(),
|
resetDay: z.number().int(),
|
||||||
resetMax: z.number().int(),
|
resetMax: z.number().int(),
|
||||||
|
resetWeekday: z.number().int(),
|
||||||
reverse: z.unknown(),
|
reverse: z.unknown(),
|
||||||
secret: z.string(),
|
secret: z.string(),
|
||||||
security: z.string(),
|
security: z.string(),
|
||||||
@@ -448,6 +515,29 @@ export const ClientRecordSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
|
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
|
||||||
|
|
||||||
|
export const ClientRenewalPreviewSchema = z.object({
|
||||||
|
canRenew: z.boolean(),
|
||||||
|
delayedStart: z.boolean(),
|
||||||
|
nextExpiry: z.string(),
|
||||||
|
renewAt: z.string(),
|
||||||
|
renewals: z.number().int(),
|
||||||
|
suggestedExpiry: z.string(),
|
||||||
|
suggestedExpiryTime: z.number().int(),
|
||||||
|
timeZone: z.string(),
|
||||||
|
validThrough: z.string(),
|
||||||
|
});
|
||||||
|
export type ClientRenewalPreview = z.infer<typeof ClientRenewalPreviewSchema>;
|
||||||
|
|
||||||
|
export const ClientRenewalPreviewRequestSchema = z.object({
|
||||||
|
expiryTime: z.number().int(),
|
||||||
|
reset: z.number().int(),
|
||||||
|
resetCount: z.number().int(),
|
||||||
|
resetDay: z.number().int(),
|
||||||
|
resetMax: z.number().int(),
|
||||||
|
resetWeekday: z.number().int(),
|
||||||
|
});
|
||||||
|
export type ClientRenewalPreviewRequest = z.infer<typeof ClientRenewalPreviewRequestSchema>;
|
||||||
|
|
||||||
export const ClientReverseSchema = z.object({
|
export const ClientReverseSchema = z.object({
|
||||||
tag: z.string(),
|
tag: z.string(),
|
||||||
});
|
});
|
||||||
@@ -466,6 +556,7 @@ export const ClientSlimSchema = z.object({
|
|||||||
reset: z.number().int(),
|
reset: z.number().int(),
|
||||||
resetDay: z.number().int(),
|
resetDay: z.number().int(),
|
||||||
resetMax: z.number().int(),
|
resetMax: z.number().int(),
|
||||||
|
resetWeekday: z.number().int(),
|
||||||
subId: z.string(),
|
subId: z.string(),
|
||||||
totalGB: z.number().int(),
|
totalGB: z.number().int(),
|
||||||
traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
|
traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
|
||||||
@@ -486,6 +577,7 @@ export const ClientTrafficSchema = z.object({
|
|||||||
resetCount: z.number().int(),
|
resetCount: z.number().int(),
|
||||||
resetDay: z.number().int(),
|
resetDay: z.number().int(),
|
||||||
resetMax: z.number().int(),
|
resetMax: z.number().int(),
|
||||||
|
resetWeekday: z.number().int(),
|
||||||
subId: z.string(),
|
subId: z.string(),
|
||||||
total: z.number().int(),
|
total: z.number().int(),
|
||||||
up: z.number().int(),
|
up: z.number().int(),
|
||||||
@@ -571,6 +663,7 @@ export const HostSchema = z.object({
|
|||||||
address: z.string(),
|
address: z.string(),
|
||||||
allowInsecure: z.boolean(),
|
allowInsecure: z.boolean(),
|
||||||
alpn: z.array(z.string()),
|
alpn: z.array(z.string()),
|
||||||
|
cipherSuites: z.string(),
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
echConfigList: z.string(),
|
echConfigList: z.string(),
|
||||||
excludeFromSubTypes: z.array(z.string()),
|
excludeFromSubTypes: z.array(z.string()),
|
||||||
@@ -608,6 +701,7 @@ export type Host = z.infer<typeof HostSchema>;
|
|||||||
export const HostGroupSchema = z.object({
|
export const HostGroupSchema = z.object({
|
||||||
allowInsecure: z.boolean(),
|
allowInsecure: z.boolean(),
|
||||||
alpn: z.array(z.string()),
|
alpn: z.array(z.string()),
|
||||||
|
cipherSuites: z.string(),
|
||||||
echConfigList: z.string(),
|
echConfigList: z.string(),
|
||||||
excludeFromSubTypes: z.array(z.string()),
|
excludeFromSubTypes: z.array(z.string()),
|
||||||
finalMask: z.string(),
|
finalMask: z.string(),
|
||||||
@@ -654,6 +748,7 @@ export const InboundSchema = z.object({
|
|||||||
disableFlow: z.boolean(),
|
disableFlow: z.boolean(),
|
||||||
down: z.number().int(),
|
down: z.number().int(),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
|
excludeFromSub: z.boolean(),
|
||||||
expiryTime: z.number().int(),
|
expiryTime: z.number().int(),
|
||||||
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
|
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
@@ -994,6 +1089,26 @@ export const SettingSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Setting = z.infer<typeof SettingSchema>;
|
export type Setting = z.infer<typeof SettingSchema>;
|
||||||
|
|
||||||
|
export const SponsorSchema = z.object({
|
||||||
|
enable: z.boolean().nullable().optional(),
|
||||||
|
from: z.string().nullable().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
link: z.string(),
|
||||||
|
logo: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
slots: z.array(z.string()),
|
||||||
|
text: z.record(z.string(), z.string()),
|
||||||
|
title: z.record(z.string(), z.string()),
|
||||||
|
until: z.string(),
|
||||||
|
});
|
||||||
|
export type Sponsor = z.infer<typeof SponsorSchema>;
|
||||||
|
|
||||||
|
export const SponsorListSchema = z.object({
|
||||||
|
contact: z.string().optional(),
|
||||||
|
sponsors: z.array(z.lazy(() => SponsorSchema)),
|
||||||
|
});
|
||||||
|
export type SponsorList = z.infer<typeof SponsorListSchema>;
|
||||||
|
|
||||||
export const SubBalancerSchema = z.object({
|
export const SubBalancerSchema = z.object({
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
|||||||
@@ -696,6 +696,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
tgId: Number(base.tgId) || 0,
|
tgId: Number(base.tgId) || 0,
|
||||||
reset: Number(base.reset) || 0,
|
reset: Number(base.reset) || 0,
|
||||||
resetDay: Number(base.resetDay) || 0,
|
resetDay: Number(base.resetDay) || 0,
|
||||||
|
resetWeekday: Number(base.resetWeekday) || 0,
|
||||||
resetMax: Number(base.resetMax) || 0,
|
resetMax: Number(base.resetMax) || 0,
|
||||||
trafficReset: base.trafficReset || 'never',
|
trafficReset: base.trafficReset || 'never',
|
||||||
trafficResetDay: Number(base.trafficResetDay) || 1,
|
trafficResetDay: Number(base.trafficResetDay) || 1,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const TITLE_KEYS: Record<string, string> = {
|
|||||||
'/outbound': 'menu.outbounds',
|
'/outbound': 'menu.outbounds',
|
||||||
'/routing': 'menu.routing',
|
'/routing': 'menu.routing',
|
||||||
'/api-docs': 'menu.apiDocs',
|
'/api-docs': 'menu.apiDocs',
|
||||||
|
'/sponsors': 'menu.sponsors',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ function readBool(key: string, fallback: boolean): boolean {
|
|||||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||||
document.body.classList.remove('dark', 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
document.body.classList.add(isDark ? 'dark' : 'light');
|
document.body.classList.add(isDark ? 'dark' : 'light');
|
||||||
|
// Native scrollbars read color-scheme, not the body class.
|
||||||
|
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||||
if (isUltra) {
|
if (isUltra) {
|
||||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -247,6 +247,10 @@
|
|||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sider-sponsor {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-pin {
|
.sidebar-pin {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
|
CrownOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
DiscordOutlined,
|
DiscordOutlined,
|
||||||
@@ -43,6 +44,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
|
|||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||||
|
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||||
import './AppSidebar.css';
|
import './AppSidebar.css';
|
||||||
|
|
||||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||||
@@ -68,6 +70,7 @@ type IconName =
|
|||||||
| 'cluster'
|
| 'cluster'
|
||||||
| 'hosts'
|
| 'hosts'
|
||||||
| 'logout'
|
| 'logout'
|
||||||
|
| 'sponsors'
|
||||||
| 'apidocs'
|
| 'apidocs'
|
||||||
| 'outbound'
|
| 'outbound'
|
||||||
| 'routing';
|
| 'routing';
|
||||||
@@ -82,6 +85,7 @@ const iconByName: Record<IconName, ComponentType> = {
|
|||||||
cluster: ClusterOutlined,
|
cluster: ClusterOutlined,
|
||||||
hosts: GlobalOutlined,
|
hosts: GlobalOutlined,
|
||||||
logout: LogoutOutlined,
|
logout: LogoutOutlined,
|
||||||
|
sponsors: CrownOutlined,
|
||||||
apidocs: ApiOutlined,
|
apidocs: ApiOutlined,
|
||||||
outbound: ExportOutlined,
|
outbound: ExportOutlined,
|
||||||
routing: SwapOutlined,
|
routing: SwapOutlined,
|
||||||
@@ -232,6 +236,7 @@ export default function AppSidebar() {
|
|||||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||||
|
{ key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
|
||||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
@@ -447,6 +452,13 @@ export default function AppSidebar() {
|
|||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
<div className="sider-footer">
|
<div className="sider-footer">
|
||||||
|
<SponsorSlot
|
||||||
|
slot="sidebar"
|
||||||
|
variant="compact"
|
||||||
|
iconOnly={railCollapsed}
|
||||||
|
rotate
|
||||||
|
className="sider-sponsor"
|
||||||
|
/>
|
||||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
</Layout.Sider>
|
</Layout.Sider>
|
||||||
@@ -532,6 +544,7 @@ export default function AppSidebar() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="drawer-footer">
|
<div className="drawer-footer">
|
||||||
|
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
|
||||||
<VersionBadge version={panelVersion} />
|
<VersionBadge version={panelVersion} />
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function formatPanelVersion(version: string | undefined | null): string {
|
|||||||
|
|
||||||
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
|
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
|
||||||
if (!latest || !current) return false;
|
if (!latest || !current) return false;
|
||||||
|
// A dev+<sha> label and a release tag sit on different channels and carry no
|
||||||
|
// order, so a node moved to the other channel is not "behind" the master's latest.
|
||||||
|
if (latest.trim().startsWith('dev+') !== current.trim().startsWith('dev+')) return false;
|
||||||
const a = parseVersionParts(latest);
|
const a = parseVersionParts(latest);
|
||||||
const b = parseVersionParts(current);
|
const b = parseVersionParts(current);
|
||||||
if (!a || !b) {
|
if (!a || !b) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Sponsor } from '@/generated/types';
|
||||||
|
|
||||||
|
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'xui.sponsor.dismissed';
|
||||||
|
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function pickLocale(map: Record<string, string> | undefined, lang: string): string {
|
||||||
|
if (!map) return '';
|
||||||
|
const short = lang.split('-')[0].toLowerCase();
|
||||||
|
return map[lang] || map[short] || map.en || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sponsorsForSlot(sponsors: Sponsor[], slot: SponsorSlot): Sponsor[] {
|
||||||
|
return sponsors.filter((s) => s.slots.includes(slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDismissed(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(localStorage.getItem(DISMISS_KEY) || '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, number>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSponsorDismissed(id: string, slot: SponsorSlot, now = Date.now()): boolean {
|
||||||
|
const at = readDismissed()[`${id}:${slot}`];
|
||||||
|
return typeof at === 'number' && now - at < SPONSOR_DISMISS_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now()) {
|
||||||
|
const next = Object.fromEntries(
|
||||||
|
Object.entries(readDismissed()).filter(([, at]) => now - at < SPONSOR_DISMISS_MS),
|
||||||
|
);
|
||||||
|
next[`${id}:${slot}`] = now;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
|
||||||
|
export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
|
||||||
|
dashboard: 1,
|
||||||
|
login: 1,
|
||||||
|
sidebar: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlacementStatus {
|
||||||
|
count: number;
|
||||||
|
capacity?: number;
|
||||||
|
takenUntil?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When full, a place frees up once enough bookings end to drop below capacity.
|
||||||
|
export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
|
||||||
|
const booked = sponsorsForSlot(sponsors, slot);
|
||||||
|
const capacity = SLOT_CAPACITY[slot];
|
||||||
|
if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
|
||||||
|
const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
|
||||||
|
return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ export interface RawInboundRow {
|
|||||||
shareAddrStrategy?: string;
|
shareAddrStrategy?: string;
|
||||||
shareAddr?: string;
|
shareAddr?: string;
|
||||||
subSortIndex?: number;
|
subSortIndex?: number;
|
||||||
|
excludeFromSub?: boolean;
|
||||||
disableFlow?: boolean;
|
disableFlow?: boolean;
|
||||||
clientStats?: unknown;
|
clientStats?: unknown;
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,7 @@ export interface WireInboundPayload {
|
|||||||
shareAddrStrategy: ShareAddrStrategy;
|
shareAddrStrategy: ShareAddrStrategy;
|
||||||
shareAddr: string;
|
shareAddr: string;
|
||||||
subSortIndex: number;
|
subSortIndex: number;
|
||||||
|
excludeFromSub: boolean;
|
||||||
disableFlow: boolean;
|
disableFlow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +221,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
|||||||
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
||||||
shareAddr: row.shareAddr ?? '',
|
shareAddr: row.shareAddr ?? '',
|
||||||
subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex,
|
subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex,
|
||||||
|
excludeFromSub: row.excludeFromSub ?? false,
|
||||||
disableFlow: row.disableFlow ?? false,
|
disableFlow: row.disableFlow ?? false,
|
||||||
protocol,
|
protocol,
|
||||||
settings,
|
settings,
|
||||||
@@ -387,6 +390,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
|||||||
shareAddrStrategy: values.shareAddrStrategy,
|
shareAddrStrategy: values.shareAddrStrategy,
|
||||||
shareAddr: values.shareAddr,
|
shareAddr: values.shareAddr,
|
||||||
subSortIndex: values.subSortIndex,
|
subSortIndex: values.subSortIndex,
|
||||||
|
excludeFromSub: values.excludeFromSub,
|
||||||
disableFlow: values.disableFlow,
|
disableFlow: values.disableFlow,
|
||||||
};
|
};
|
||||||
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
||||||
|
|||||||
@@ -786,7 +786,8 @@ function dnsRuleToWire(r: DnsRuleForm) {
|
|||||||
const result: Raw = { action };
|
const result: Raw = { action };
|
||||||
const qType = r.qType.trim();
|
const qType = r.qType.trim();
|
||||||
if (qType) {
|
if (qType) {
|
||||||
result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
|
// The core reads a numeric 0 as no qType at all, which matches every query.
|
||||||
|
result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
|
||||||
}
|
}
|
||||||
const domains = r.domain
|
const domains = r.domain
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type DBInboundInit = Partial<{
|
|||||||
shareAddrStrategy: string;
|
shareAddrStrategy: string;
|
||||||
shareAddr: string;
|
shareAddr: string;
|
||||||
subSortIndex: number;
|
subSortIndex: number;
|
||||||
|
excludeFromSub: boolean;
|
||||||
disableFlow: boolean;
|
disableFlow: boolean;
|
||||||
originNodeGuid: string;
|
originNodeGuid: string;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
@@ -93,6 +94,7 @@ export class DBInbound {
|
|||||||
shareAddrStrategy: string;
|
shareAddrStrategy: string;
|
||||||
shareAddr: string;
|
shareAddr: string;
|
||||||
subSortIndex: number;
|
subSortIndex: number;
|
||||||
|
excludeFromSub: boolean;
|
||||||
disableFlow: boolean;
|
disableFlow: boolean;
|
||||||
originNodeGuid: string;
|
originNodeGuid: string;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
@@ -124,6 +126,7 @@ export class DBInbound {
|
|||||||
this.shareAddrStrategy = 'node';
|
this.shareAddrStrategy = 'node';
|
||||||
this.shareAddr = '';
|
this.shareAddr = '';
|
||||||
this.subSortIndex = 1;
|
this.subSortIndex = 1;
|
||||||
|
this.excludeFromSub = false;
|
||||||
this.disableFlow = false;
|
this.disableFlow = false;
|
||||||
this.originNodeGuid = '';
|
this.originNodeGuid = '';
|
||||||
this.fallbackParent = null;
|
this.fallbackParent = null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ObjectUtil } from '@/utils';
|
import { ObjectUtil } from '@/utils';
|
||||||
|
import type { SubProfileMode } from '@/schemas/setting';
|
||||||
|
|
||||||
export class AllSetting {
|
export class AllSetting {
|
||||||
webListen = '';
|
webListen = '';
|
||||||
@@ -46,6 +47,7 @@ export class AllSetting {
|
|||||||
subClashUserAgentRegex = '';
|
subClashUserAgentRegex = '';
|
||||||
subTitle = '';
|
subTitle = '';
|
||||||
subSupportUrl = '';
|
subSupportUrl = '';
|
||||||
|
subProfileMode: SubProfileMode = 'none';
|
||||||
subProfileUrl = '';
|
subProfileUrl = '';
|
||||||
subAnnounce = '';
|
subAnnounce = '';
|
||||||
subEnableRouting = false;
|
subEnableRouting = false;
|
||||||
@@ -64,6 +66,7 @@ export class AllSetting {
|
|||||||
restartXrayOnClientDisable = true;
|
restartXrayOnClientDisable = true;
|
||||||
subCertFile = '';
|
subCertFile = '';
|
||||||
subKeyFile = '';
|
subKeyFile = '';
|
||||||
|
externalSubUserAgent = 'v2rayNG/1.8.5';
|
||||||
subUpdates = 12;
|
subUpdates = 12;
|
||||||
subEncrypt = true;
|
subEncrypt = true;
|
||||||
subURI = '';
|
subURI = '';
|
||||||
@@ -102,6 +105,36 @@ export class AllSetting {
|
|||||||
subHappAutoConnectType = 'lowestdelay';
|
subHappAutoConnectType = 'lowestdelay';
|
||||||
subHappPerAppMode = 'off';
|
subHappPerAppMode = 'off';
|
||||||
subHappPerAppList = '';
|
subHappPerAppList = '';
|
||||||
|
subHappLocalProxyAuth = 'auto';
|
||||||
|
|
||||||
|
subIncyAppAutoDetect = false;
|
||||||
|
subIncyProfileDescription = '';
|
||||||
|
subIncySortOrder = '';
|
||||||
|
subIncySupportEmail = '';
|
||||||
|
subIncyAnnounceUrl = '';
|
||||||
|
subIncyPremiumUrl = '';
|
||||||
|
subIncyBannerText = '';
|
||||||
|
subIncyBannerButtonText = '';
|
||||||
|
subIncyBannerButtonUrl = '';
|
||||||
|
subIncyBannerBgColor = '';
|
||||||
|
subIncyBannerButtonColor = '';
|
||||||
|
subIncyHideUrl = '';
|
||||||
|
subIncyHideCheck = '';
|
||||||
|
subIncyNoLimitEnabled = '';
|
||||||
|
subIncyPerAppEnable = '';
|
||||||
|
subIncyPerAppMode = '';
|
||||||
|
subIncyPerAppList = '';
|
||||||
|
subIncyFragmentationEnable = '';
|
||||||
|
subIncyFragmentLength = '';
|
||||||
|
subIncyFragmentInterval = '';
|
||||||
|
subIncyFragmentPackets = '';
|
||||||
|
subIncyNoisesEnable = '';
|
||||||
|
subIncyNoisesType = '';
|
||||||
|
subIncyNoisesPacket = '';
|
||||||
|
subIncyNoisesDelay = '';
|
||||||
|
subIncyResolveEnable = '';
|
||||||
|
subIncyResolveDnsDomain = '';
|
||||||
|
subIncyResolveDnsIp = '';
|
||||||
|
|
||||||
timeLocation = 'Local';
|
timeLocation = 'Local';
|
||||||
|
|
||||||
@@ -167,6 +200,15 @@ export class AllSetting {
|
|||||||
if (data != null) {
|
if (data != null) {
|
||||||
ObjectUtil.cloneProps(this, data);
|
ObjectUtil.cloneProps(this, data);
|
||||||
}
|
}
|
||||||
|
// Legacy settings with a custom URL retain it until an explicit mode is saved.
|
||||||
|
if (
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
(!('subProfileMode' in data) || data.subProfileMode === undefined) &&
|
||||||
|
this.subProfileUrl.trim() !== ''
|
||||||
|
) {
|
||||||
|
this.subProfileMode = 'custom';
|
||||||
|
}
|
||||||
const cpu = Math.round(Number(this.tgCpu));
|
const cpu = Math.round(Number(this.tgCpu));
|
||||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||||
|
|||||||
@@ -45,15 +45,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events {
|
.api-docs-page .websocket-events {
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events h2 {
|
.api-docs-page .swagger-ui .section-tabs {
|
||||||
margin-top: 0;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events pre {
|
.api-docs-page .websocket-events pre {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
|
import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
|
||||||
import SwaggerUI from 'swagger-ui-react';
|
import SwaggerUI from 'swagger-ui-react';
|
||||||
import 'swagger-ui-react/swagger-ui.css';
|
import 'swagger-ui-react/swagger-ui.css';
|
||||||
|
|
||||||
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
|
|||||||
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||||
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
||||||
|
|
||||||
|
interface TaggedOperations {
|
||||||
|
keySeq: () => { first: () => string | undefined };
|
||||||
|
filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutSelectors {
|
||||||
|
currentFilter: () => string | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionTabsProps {
|
||||||
|
specSelectors: { tags: () => { toJS: () => { name: string }[] } };
|
||||||
|
layoutSelectors: LayoutSelectors;
|
||||||
|
layoutActions: { updateFilter: (tag: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
|
||||||
|
const tags = specSelectors
|
||||||
|
.tags()
|
||||||
|
.toJS()
|
||||||
|
.map((tag) => tag.name);
|
||||||
|
return (
|
||||||
|
<div className="wrapper section-tabs">
|
||||||
|
<Tabs
|
||||||
|
size="small"
|
||||||
|
activeKey={layoutSelectors.currentFilter() || tags[0]}
|
||||||
|
onChange={layoutActions.updateFilter}
|
||||||
|
items={tags.map((tag) => ({ key: tag, label: tag }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
|
||||||
|
// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
|
||||||
|
const sectionTabsPlugin = {
|
||||||
|
statePlugins: {
|
||||||
|
spec: {
|
||||||
|
wrapSelectors: {
|
||||||
|
taggedOperations:
|
||||||
|
(
|
||||||
|
select: (...args: unknown[]) => TaggedOperations,
|
||||||
|
system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
|
||||||
|
) =>
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
const operations = select(...args);
|
||||||
|
const active =
|
||||||
|
system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
|
||||||
|
return operations.filter((_, tag) => tag === active);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: { FilterContainer: SectionTabs },
|
||||||
|
};
|
||||||
|
|
||||||
export default function ApiDocsPage() {
|
export default function ApiDocsPage() {
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,20 +87,43 @@ export default function ApiDocsPage() {
|
|||||||
|
|
||||||
<Layout className="content-shell">
|
<Layout className="content-shell">
|
||||||
<Layout.Content className="content-area">
|
<Layout.Content className="content-area">
|
||||||
<section className="websocket-events" aria-labelledby="websocket-events-title">
|
<Tabs
|
||||||
<Typography.Title id="websocket-events-title" level={2}>
|
items={[
|
||||||
WebSocket events
|
{
|
||||||
</Typography.Title>
|
key: 'panel-api',
|
||||||
|
label: '3X-UI Panel API',
|
||||||
|
children: (
|
||||||
|
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||||
|
<SwaggerUI
|
||||||
|
url={openApiUrl}
|
||||||
|
docExpansion="list"
|
||||||
|
deepLinking={false}
|
||||||
|
plugins={[sectionTabsPlugin]}
|
||||||
|
tryItOutEnabled
|
||||||
|
persistAuthorization
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'websocket-events',
|
||||||
|
label: 'WebSocket events',
|
||||||
|
children: (
|
||||||
|
<section className="websocket-events">
|
||||||
<Typography.Paragraph>
|
<Typography.Paragraph>
|
||||||
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
|
After the cookie-authenticated{' '}
|
||||||
upgrade, every server message uses{' '}
|
<Typography.Text code>GET /ws</Typography.Text> upgrade, every server
|
||||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
|
message uses{' '}
|
||||||
is Unix milliseconds.
|
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
|
||||||
|
time value is Unix milliseconds.
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Row gutter={[12, 12]}>
|
<Row gutter={[12, 12]}>
|
||||||
{websocketEvents.map((event) => (
|
{websocketEvents.map((event) => (
|
||||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||||
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Typography.Text code>{event.type}</Typography.Text>}
|
||||||
|
>
|
||||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -53,15 +131,10 @@ export default function ApiDocsPage() {
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
</section>
|
</section>
|
||||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
),
|
||||||
<SwaggerUI
|
},
|
||||||
url={openApiUrl}
|
]}
|
||||||
docExpansion="list"
|
|
||||||
deepLinking={false}
|
|
||||||
tryItOutEnabled
|
|
||||||
persistAuthorization
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -236,6 +236,13 @@ export const sections: readonly Section[] = [
|
|||||||
'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
|
'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
|
||||||
response: '{\n "success": true,\n "obj": "csrf-token-string"\n}',
|
response: '{\n "success": true,\n "obj": "csrf-token-string"\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/sponsors',
|
||||||
|
summary:
|
||||||
|
'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
|
||||||
|
responseSchema: 'SponsorList',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/getTwoFactorEnable',
|
path: '/getTwoFactorEnable',
|
||||||
@@ -1162,6 +1169,52 @@ export const sections: readonly Section[] = [
|
|||||||
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "limitHwid": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}',
|
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "limitHwid": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}',
|
||||||
response: '{\n "success": true,\n "msg": "Client added"\n}',
|
response: '{\n "success": true,\n "msg": "Client added"\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/renewalPreview',
|
||||||
|
summary: 'Preview client auto-renewal dates without saving or resetting anything.',
|
||||||
|
description:
|
||||||
|
'Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'expiryTime',
|
||||||
|
in: 'body (json)',
|
||||||
|
type: 'integer',
|
||||||
|
desc: 'Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reset',
|
||||||
|
in: 'body (json)',
|
||||||
|
type: 'integer',
|
||||||
|
desc: 'Fixed interval in days; 0 disabled.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resetDay',
|
||||||
|
in: 'body (json)',
|
||||||
|
type: 'integer',
|
||||||
|
desc: 'Monthly calendar day 1-31; 0 disabled.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resetWeekday',
|
||||||
|
in: 'body (json)',
|
||||||
|
type: 'integer',
|
||||||
|
desc: 'Weekly calendar day 1-7 (Monday-Sunday); 0 disabled.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resetMax',
|
||||||
|
in: 'body (json)',
|
||||||
|
type: 'integer',
|
||||||
|
desc: 'Maximum renewals; 0 unlimited.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resetCount',
|
||||||
|
in: 'body (json)',
|
||||||
|
type: 'integer',
|
||||||
|
desc: 'Renewals already consumed; defaults to 0.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responseSchema: 'ClientRenewalPreview',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/update/:email',
|
path: '/panel/api/clients/update/:email',
|
||||||
@@ -1276,15 +1329,15 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/clients/export',
|
path: '/panel/api/clients/export',
|
||||||
summary:
|
summary:
|
||||||
'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
|
'Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": [\n {\n "client": {\n "email": "alice@example.com",\n "id": "...",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "limitHwid": 2,\n "enable": true,\n "subId": "..."\n },\n "inboundIds": [7, 9]\n }\n ]\n}',
|
'{\n "success": true,\n "obj": [\n {\n "client": {\n "email": "alice@example.com",\n "id": "...",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "limitHwid": 2,\n "enable": true,\n "subId": "..."\n },\n "inboundIds": [7, 9],\n "traffic": {\n "up": 1048576,\n "down": 2097152,\n "resetCount": 0,\n "lastOnline": 1735680000000\n }\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/import',
|
path: '/panel/api/clients/import',
|
||||||
summary:
|
summary:
|
||||||
'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.',
|
'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.',
|
||||||
body: '{\n "data": "[{\\"client\\":{\\"email\\":\\"alice@example.com\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}',
|
body: '{\n "data": "[{\\"client\\":{\\"email\\":\\"alice@example.com\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}',
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": {\n "created": 2,\n "skipped": [\n { "email": "alice@example.com", "reason": "email already in use: alice@example.com" }\n ]\n }\n}',
|
'{\n "success": true,\n "obj": {\n "created": 2,\n "skipped": [\n { "email": "alice@example.com", "reason": "email already in use: alice@example.com" }\n ]\n }\n}',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
|||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField } from '@/components/form/rhf';
|
||||||
import { useClients, type InboundOption } from '@/hooks/useClients';
|
import { useClients, type InboundOption } from '@/hooks/useClients';
|
||||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||||
|
import ClientRenewalFields from './ClientRenewalFields';
|
||||||
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
|
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
|
||||||
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
@@ -57,6 +58,7 @@ const EMPTY: ClientBulkAddFormValues = {
|
|||||||
expiryTime: 0,
|
expiryTime: 0,
|
||||||
reset: 0,
|
reset: 0,
|
||||||
resetDay: 0,
|
resetDay: 0,
|
||||||
|
resetWeekday: 0,
|
||||||
resetMax: 0,
|
resetMax: 0,
|
||||||
trafficReset: 'never' as const,
|
trafficReset: 'never' as const,
|
||||||
trafficResetDay: 1,
|
trafficResetDay: 1,
|
||||||
@@ -215,6 +217,7 @@ export default function ClientBulkAddModal({
|
|||||||
expiryTime: current.expiryTime,
|
expiryTime: current.expiryTime,
|
||||||
reset: Number(current.reset) || 0,
|
reset: Number(current.reset) || 0,
|
||||||
resetDay: Number(current.resetDay) || 0,
|
resetDay: Number(current.resetDay) || 0,
|
||||||
|
resetWeekday: Number(current.resetWeekday) || 0,
|
||||||
resetMax: Number(current.resetMax) || 0,
|
resetMax: Number(current.resetMax) || 0,
|
||||||
trafficReset: current.trafficReset || 'never',
|
trafficReset: current.trafficReset || 'never',
|
||||||
trafficResetDay: Number(current.trafficResetDay) || 1,
|
trafficResetDay: Number(current.trafficResetDay) || 1,
|
||||||
@@ -437,32 +440,13 @@ export default function ClientBulkAddModal({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
<ClientRenewalFields
|
||||||
name="reset"
|
active={open}
|
||||||
label={t('pages.clients.renew')}
|
delayedStart={delayedStart}
|
||||||
tooltip={t('pages.clients.renewDesc')}
|
expiryTime={expiryTime}
|
||||||
transform={{ output: (v) => Number(v) || 0 }}
|
bulk
|
||||||
>
|
setExpiry={(expiry) => methods.setValue('expiryTime', expiry)}
|
||||||
<InputNumber min={0} />
|
/>
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="resetDay"
|
|
||||||
label={t('pages.clients.renewOnDay')}
|
|
||||||
tooltip={t('pages.clients.renewOnDayDesc')}
|
|
||||||
transform={{ output: (v) => Number(v) || 0 }}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} max={31} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="resetMax"
|
|
||||||
label={t('pages.clients.renewMax')}
|
|
||||||
tooltip={t('pages.clients.renewMaxDesc')}
|
|
||||||
transform={{ output: (v) => Number(v) || 0 }}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* The body is capped at the viewport and scrolls; a trailing item margin
|
||||||
|
alone must not push it past the cap and summon a scrollbar. */
|
||||||
|
.client-form-modal .ant-tabs-content > .ant-form-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
@@ -48,7 +48,9 @@ import type {
|
|||||||
ExternalLinkInput,
|
ExternalLinkInput,
|
||||||
} from '@/hooks/useClients';
|
} from '@/hooks/useClients';
|
||||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||||
|
import ClientRenewalFields from './ClientRenewalFields';
|
||||||
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
||||||
|
import './ClientFormModal.css';
|
||||||
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
|
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
|
||||||
@@ -154,6 +156,7 @@ const EMPTY: Values = {
|
|||||||
delayedDays: 0,
|
delayedDays: 0,
|
||||||
reset: 0,
|
reset: 0,
|
||||||
resetDay: 0,
|
resetDay: 0,
|
||||||
|
resetWeekday: 0,
|
||||||
resetMax: 0,
|
resetMax: 0,
|
||||||
trafficReset: 'never' as const,
|
trafficReset: 'never' as const,
|
||||||
trafficResetDay: 1,
|
trafficResetDay: 1,
|
||||||
@@ -258,6 +261,7 @@ export default function ClientFormModal({
|
|||||||
const methods = useForm<Values>({ defaultValues: EMPTY });
|
const methods = useForm<Values>({ defaultValues: EMPTY });
|
||||||
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
||||||
const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
|
const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
|
||||||
|
const delayedDays = useWatch({ control: methods.control, name: 'delayedDays' });
|
||||||
const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
|
const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
|
||||||
const enable = useWatch({ control: methods.control, name: 'enable' });
|
const enable = useWatch({ control: methods.control, name: 'enable' });
|
||||||
const flow = useWatch({ control: methods.control, name: 'flow' });
|
const flow = useWatch({ control: methods.control, name: 'flow' });
|
||||||
@@ -365,6 +369,7 @@ export default function ClientFormModal({
|
|||||||
totalGB: bytesToGB(client.totalGB || 0),
|
totalGB: bytesToGB(client.totalGB || 0),
|
||||||
reset: Number(client.reset) || 0,
|
reset: Number(client.reset) || 0,
|
||||||
resetDay: Number(client.resetDay) || 0,
|
resetDay: Number(client.resetDay) || 0,
|
||||||
|
resetWeekday: Number(client.resetWeekday) || 0,
|
||||||
resetMax: Number(client.resetMax) || 0,
|
resetMax: Number(client.resetMax) || 0,
|
||||||
trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
|
trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
|
||||||
trafficResetDay: Number(client.trafficResetDay) || 1,
|
trafficResetDay: Number(client.trafficResetDay) || 1,
|
||||||
@@ -662,6 +667,7 @@ export default function ClientFormModal({
|
|||||||
delayedDays: values.delayedDays,
|
delayedDays: values.delayedDays,
|
||||||
reset: values.reset,
|
reset: values.reset,
|
||||||
resetDay: values.resetDay,
|
resetDay: values.resetDay,
|
||||||
|
resetWeekday: values.resetWeekday,
|
||||||
resetMax: values.resetMax,
|
resetMax: values.resetMax,
|
||||||
trafficReset: values.trafficReset,
|
trafficReset: values.trafficReset,
|
||||||
trafficResetDay: values.trafficResetDay,
|
trafficResetDay: values.trafficResetDay,
|
||||||
@@ -695,6 +701,7 @@ export default function ClientFormModal({
|
|||||||
expiryTime,
|
expiryTime,
|
||||||
reset: Number(values.reset) || 0,
|
reset: Number(values.reset) || 0,
|
||||||
resetDay: Number(values.resetDay) || 0,
|
resetDay: Number(values.resetDay) || 0,
|
||||||
|
resetWeekday: Number(values.resetWeekday) || 0,
|
||||||
resetMax: Number(values.resetMax) || 0,
|
resetMax: Number(values.resetMax) || 0,
|
||||||
trafficReset: values.trafficReset || 'never',
|
trafficReset: values.trafficReset || 'never',
|
||||||
trafficResetDay: Number(values.trafficResetDay) || 1,
|
trafficResetDay: Number(values.trafficResetDay) || 1,
|
||||||
@@ -803,6 +810,7 @@ export default function ClientFormModal({
|
|||||||
open={open}
|
open={open}
|
||||||
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
|
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
|
className="client-form-modal"
|
||||||
width={720}
|
width={720}
|
||||||
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
|
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
|
||||||
style={{ top: 20 }}
|
style={{ top: 20 }}
|
||||||
@@ -983,37 +991,10 @@ export default function ClientFormModal({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
</Row>
|
||||||
<FormField
|
|
||||||
name="reset"
|
<Row gutter={16}>
|
||||||
label={t('pages.clients.renewDays')}
|
<Col xs={24} md={12}>
|
||||||
tooltip={t('pages.clients.renewDesc')}
|
|
||||||
transform={{ output: (v) => Number(v) || 0 }}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} md={6}>
|
|
||||||
<FormField
|
|
||||||
name="resetDay"
|
|
||||||
label={t('pages.clients.renewOnDay')}
|
|
||||||
tooltip={t('pages.clients.renewOnDayDesc')}
|
|
||||||
transform={{ output: (v) => Number(v) || 0 }}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} max={31} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} md={6}>
|
|
||||||
<FormField
|
|
||||||
name="resetMax"
|
|
||||||
label={t('pages.clients.renewMax')}
|
|
||||||
tooltip={t('pages.clients.renewMaxDesc')}
|
|
||||||
transform={{ output: (v) => Number(v) || 0 }}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} md={6}>
|
|
||||||
<FormField
|
<FormField
|
||||||
name="trafficReset"
|
name="trafficReset"
|
||||||
label={t('pages.inbounds.periodicTrafficResetTitle')}
|
label={t('pages.inbounds.periodicTrafficResetTitle')}
|
||||||
@@ -1025,9 +1006,7 @@ export default function ClientFormModal({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</Col>
|
|
||||||
{trafficReset === 'monthly' && (
|
{trafficReset === 'monthly' && (
|
||||||
<Col xs={12} md={6}>
|
|
||||||
<FormField
|
<FormField
|
||||||
name="trafficResetDay"
|
name="trafficResetDay"
|
||||||
label={t('pages.inbounds.periodicTrafficResetDay')}
|
label={t('pages.inbounds.periodicTrafficResetDay')}
|
||||||
@@ -1035,8 +1014,19 @@ export default function ClientFormModal({
|
|||||||
>
|
>
|
||||||
<InputNumber min={1} max={31} style={{ width: '100%' }} />
|
<InputNumber min={1} max={31} style={{ width: '100%' }} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</Col>
|
|
||||||
)}
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ClientRenewalFields
|
||||||
|
active={open}
|
||||||
|
delayedStart={delayedStart}
|
||||||
|
expiryTime={
|
||||||
|
delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0
|
||||||
|
}
|
||||||
|
resetCount={client?.traffic?.resetCount || 0}
|
||||||
|
setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
@@ -1162,7 +1152,10 @@ export default function ClientFormModal({
|
|||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t('pages.clients.subId')}>
|
<Form.Item
|
||||||
|
label={t('pages.clients.subId')}
|
||||||
|
tooltip={t('pages.clients.subIdDesc')}
|
||||||
|
>
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
<Input
|
<Input
|
||||||
value={subId}
|
value={subId}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type QrVariant = 'standard' | 'happ';
|
|||||||
type HappError = 'too_long' | 'unavailable' | null;
|
type HappError = 'too_long' | 'unavailable' | null;
|
||||||
|
|
||||||
const HAPP_CRYPT5_PREFIX = 'happ://crypt5/';
|
const HAPP_CRYPT5_PREFIX = 'happ://crypt5/';
|
||||||
const HAPP_SETTINGS_PATH = '/settings?subscriptionTab=happ&happTab=links#subscription';
|
const HAPP_SETTINGS_PATH = '/settings?subscriptionTab=happ#subscription';
|
||||||
// QrPanel encodes at error level L; QR version 40 holds 2953 UTF-8 bytes at that level.
|
// QrPanel encodes at error level L; QR version 40 holds 2953 UTF-8 bytes at that level.
|
||||||
const HAPP_QR_MAX_BYTES = 2953;
|
const HAPP_QR_MAX_BYTES = 2953;
|
||||||
const UTF8_ENCODER = new TextEncoder();
|
const UTF8_ENCODER = new TextEncoder();
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useId, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Button, Form, InputNumber, Select, Space, Typography } from 'antd';
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form/rhf';
|
||||||
|
import { ClientRenewalPreviewSchema } from '@/generated/zod';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import type { ClientFormValues } from '@/schemas/client';
|
||||||
|
|
||||||
|
type RenewalFields = Pick<ClientFormValues, 'reset' | 'resetDay' | 'resetWeekday' | 'resetMax'>;
|
||||||
|
type RenewalMode = 'none' | 'interval' | 'weekly' | 'monthly';
|
||||||
|
|
||||||
|
export default function ClientRenewalFields({
|
||||||
|
active,
|
||||||
|
expiryTime,
|
||||||
|
resetCount = 0,
|
||||||
|
bulk = false,
|
||||||
|
delayedStart = false,
|
||||||
|
setExpiry,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
expiryTime: number;
|
||||||
|
resetCount?: number;
|
||||||
|
bulk?: boolean;
|
||||||
|
delayedStart?: boolean;
|
||||||
|
setExpiry: (expiry: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const formId = useId();
|
||||||
|
const modeId = 'client-renewal-mode-' + formId;
|
||||||
|
const { control, setValue } = useFormContext<RenewalFields>();
|
||||||
|
const [reset, resetDay, resetWeekday, resetMax] = useWatch({
|
||||||
|
control,
|
||||||
|
name: ['reset', 'resetDay', 'resetWeekday', 'resetMax'],
|
||||||
|
});
|
||||||
|
const mode: RenewalMode =
|
||||||
|
resetDay > 0 ? 'monthly' : resetWeekday > 0 ? 'weekly' : reset > 0 ? 'interval' : 'none';
|
||||||
|
const request = useMemo(
|
||||||
|
() => ({
|
||||||
|
expiryTime,
|
||||||
|
reset: reset || 0,
|
||||||
|
resetDay: resetDay || 0,
|
||||||
|
resetWeekday: resetWeekday || 0,
|
||||||
|
resetMax: resetMax || 0,
|
||||||
|
resetCount,
|
||||||
|
}),
|
||||||
|
[expiryTime, reset, resetDay, resetWeekday, resetMax, resetCount],
|
||||||
|
);
|
||||||
|
const [debounced, setDebounced] = useState(request);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebounced(request), 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [request]);
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['clients', 'renewalPreview', debounced],
|
||||||
|
enabled: active && mode !== 'none' && request === debounced,
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/clients/renewalPreview', debounced, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Renewal preview failed');
|
||||||
|
return ClientRenewalPreviewSchema.parse(msg.obj);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const preview = request === debounced ? query.data : undefined;
|
||||||
|
const weekdayFormatter = new Intl.DateTimeFormat(i18n.language, {
|
||||||
|
weekday: 'long',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
function changeMode(next: RenewalMode) {
|
||||||
|
setValue('reset', next === 'interval' ? Math.max(1, reset || 0) : 0);
|
||||||
|
setValue('resetDay', next === 'monthly' ? Math.max(1, resetDay || 0) : 0);
|
||||||
|
setValue('resetWeekday', next === 'weekly' ? Math.max(1, resetWeekday || 0) : 0);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item label={t('pages.clients.renewMode')} htmlFor={modeId}>
|
||||||
|
<Select
|
||||||
|
id={modeId}
|
||||||
|
value={mode}
|
||||||
|
onChange={changeMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: t('pages.clients.renewModeNone') },
|
||||||
|
{ value: 'interval', label: t('pages.clients.renewModeInterval') },
|
||||||
|
{ value: 'weekly', label: t('pages.clients.renewModeWeekly') },
|
||||||
|
{ value: 'monthly', label: t('pages.clients.renewModeMonthly') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{mode === 'interval' && (
|
||||||
|
<FormField
|
||||||
|
name="reset"
|
||||||
|
label={bulk ? t('pages.clients.renew') : t('pages.clients.renewDays')}
|
||||||
|
tooltip={t('pages.clients.renewDesc')}
|
||||||
|
transform={{ output: (v) => Number(v) || 1 }}
|
||||||
|
>
|
||||||
|
<InputNumber id={'client-renewal-interval-' + formId} min={1} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{mode === 'monthly' && (
|
||||||
|
<FormField
|
||||||
|
name="resetDay"
|
||||||
|
label={t('pages.clients.renewOnDay')}
|
||||||
|
tooltip={t('pages.clients.renewOnDayDesc')}
|
||||||
|
transform={{ output: (v) => Number(v) || 1 }}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
id={'client-renewal-day-' + formId}
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{mode === 'weekly' && (
|
||||||
|
<FormField name="resetWeekday" label={t('pages.clients.renewWeekday')}>
|
||||||
|
<Select
|
||||||
|
id={'client-renewal-weekday-' + formId}
|
||||||
|
options={Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
value: i + 1,
|
||||||
|
label: weekdayFormatter.format(new Date(Date.UTC(2026, 0, i + 5))),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{mode !== 'none' && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
name="resetMax"
|
||||||
|
label={t('pages.clients.renewMax')}
|
||||||
|
tooltip={t('pages.clients.renewMaxDesc')}
|
||||||
|
transform={{ output: (v) => Number(v) || 0 }}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{t('pages.clients.renewScheduleDesc')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{query.isError && request === debounced && (
|
||||||
|
<Typography.Paragraph type="warning">
|
||||||
|
{t('pages.clients.renewPreviewError')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
{preview && (
|
||||||
|
<Space orientation="vertical" size={4} style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Text>
|
||||||
|
{t('pages.clients.renewPreview', { zone: preview.timeZone })}
|
||||||
|
</Typography.Text>
|
||||||
|
{delayedStart || preview.delayedStart ? (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.clients.renewFirstUse')}
|
||||||
|
</Typography.Text>
|
||||||
|
) : expiryTime === 0 ? (
|
||||||
|
<>
|
||||||
|
<Typography.Text type="warning">
|
||||||
|
{t('pages.clients.renewNeedsExpiry')}
|
||||||
|
</Typography.Text>
|
||||||
|
{preview.suggestedExpiryTime > 0 && (
|
||||||
|
<Button onClick={() => setExpiry(preview.suggestedExpiryTime)}>
|
||||||
|
{t('pages.clients.renewSetExpiry')}: {preview.suggestedExpiry}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography.Text>
|
||||||
|
{t('pages.clients.renewAt')}: {preview.renewAt}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
{t('pages.clients.renewValidThrough')}: {preview.validThrough}
|
||||||
|
</Typography.Text>
|
||||||
|
{preview.nextExpiry && (
|
||||||
|
<Typography.Text>
|
||||||
|
{t('pages.clients.renewNextExpiry')}: {preview.nextExpiry}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
<Typography.Text>
|
||||||
|
{t('pages.clients.renewPeriods', { count: preview.renewals })}
|
||||||
|
</Typography.Text>
|
||||||
|
{!preview.canRenew && (
|
||||||
|
<Typography.Text type="warning">
|
||||||
|
{t('pages.clients.renewUnavailable')}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,23 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-stat {
|
||||||
|
margin: -4px -8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat:hover,
|
||||||
|
.summary-stat:focus-visible {
|
||||||
|
background: var(--ant-color-fill-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat.selected {
|
||||||
|
background: var(--ant-color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useLocation, useSearchParams } from 'react-router';
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -153,6 +154,40 @@ function ClientEmailList({ emails, total }: { emails: string[]; total: number })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SummaryStatProps {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
prefix: ReactNode;
|
||||||
|
emails?: string[];
|
||||||
|
selected?: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryStat({ title, value, prefix, emails, selected, onSelect }: SummaryStatProps) {
|
||||||
|
const stat = (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={selected ? 'summary-stat selected' : 'summary-stat'}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={activateOnKey(onSelect)}
|
||||||
|
>
|
||||||
|
<Statistic title={title} value={String(value)} prefix={prefix} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!emails) return stat;
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
title={title}
|
||||||
|
open={value ? undefined : false}
|
||||||
|
content={<ClientEmailList emails={emails} total={value} />}
|
||||||
|
>
|
||||||
|
{stat}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||||
|
|
||||||
interface PersistedFilterState {
|
interface PersistedFilterState {
|
||||||
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
|
|||||||
const someSelected =
|
const someSelected =
|
||||||
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
||||||
|
|
||||||
|
const isOnlyBucket = (bucket: string) =>
|
||||||
|
filters.buckets.length === 1 && filters.buckets[0] === bucket;
|
||||||
|
|
||||||
|
// Clicking the card that is already the sole status filter clears it again.
|
||||||
|
function selectBucket(bucket: string | null) {
|
||||||
|
const buckets = bucket && !isOnlyBucket(bucket) ? [bucket] : [];
|
||||||
|
setFilters({ ...filters, buckets });
|
||||||
|
}
|
||||||
|
|
||||||
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
||||||
if (key === 'expiryFrom' || key === 'expiryTo') {
|
if (key === 'expiryFrom' || key === 'expiryTo') {
|
||||||
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
||||||
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
|
|||||||
<Card size="small" hoverable className="summary-card">
|
<Card size="small" hoverable className="summary-card">
|
||||||
<Row gutter={[16, 12]}>
|
<Row gutter={[16, 12]}>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Statistic
|
<SummaryStat
|
||||||
title={t('clients')}
|
title={t('clients')}
|
||||||
value={String(summary.total)}
|
value={summary.total}
|
||||||
prefix={<TeamOutlined />}
|
prefix={<TeamOutlined />}
|
||||||
|
onSelect={() => selectBucket(null)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('online')}
|
title={t('online')}
|
||||||
open={summary.onlineCount ? undefined : false}
|
value={summary.onlineCount}
|
||||||
content={
|
|
||||||
<ClientEmailList
|
|
||||||
emails={summary.online}
|
emails={summary.online}
|
||||||
total={summary.onlineCount}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('online')}
|
|
||||||
value={String(summary.onlineCount)}
|
|
||||||
prefix={<span className="dot dot-blue" />}
|
prefix={<span className="dot dot-blue" />}
|
||||||
|
selected={isOnlyBucket('online')}
|
||||||
|
onSelect={() => selectBucket('online')}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('depleted')}
|
title={t('depleted')}
|
||||||
open={summary.depletedCount ? undefined : false}
|
value={summary.depletedCount}
|
||||||
content={
|
|
||||||
<ClientEmailList
|
|
||||||
emails={summary.depleted}
|
emails={summary.depleted}
|
||||||
total={summary.depletedCount}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('depleted')}
|
|
||||||
value={String(summary.depletedCount)}
|
|
||||||
prefix={<span className="dot dot-red" />}
|
prefix={<span className="dot dot-red" />}
|
||||||
|
selected={isOnlyBucket('depleted')}
|
||||||
|
onSelect={() => selectBucket('depleted')}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('depletingSoon')}
|
title={t('depletingSoon')}
|
||||||
open={summary.expiringCount ? undefined : false}
|
value={summary.expiringCount}
|
||||||
content={
|
|
||||||
<ClientEmailList
|
|
||||||
emails={summary.expiring}
|
emails={summary.expiring}
|
||||||
total={summary.expiringCount}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('depletingSoon')}
|
|
||||||
value={String(summary.expiringCount)}
|
|
||||||
prefix={<span className="dot dot-orange" />}
|
prefix={<span className="dot dot-orange" />}
|
||||||
|
selected={isOnlyBucket('expiring')}
|
||||||
|
onSelect={() => selectBucket('expiring')}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('disabled')}
|
title={t('disabled')}
|
||||||
open={summary.deactiveCount ? undefined : false}
|
value={summary.deactiveCount}
|
||||||
content={
|
|
||||||
<ClientEmailList
|
|
||||||
emails={summary.deactive}
|
emails={summary.deactive}
|
||||||
total={summary.deactiveCount}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('disabled')}
|
|
||||||
value={String(summary.deactiveCount)}
|
|
||||||
prefix={<span className="dot dot-gray" />}
|
prefix={<span className="dot dot-gray" />}
|
||||||
|
selected={isOnlyBucket('deactive')}
|
||||||
|
onSelect={() => selectBucket('deactive')}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Statistic
|
<SummaryStat
|
||||||
title={t('subscription.active')}
|
title={t('subscription.active')}
|
||||||
value={String(summary.active)}
|
value={summary.active}
|
||||||
prefix={<span className="dot dot-green" />}
|
prefix={<span className="dot dot-green" />}
|
||||||
|
selected={isOnlyBucket('active')}
|
||||||
|
onSelect={() => selectBucket('active')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
|
|||||||
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
|
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
|
||||||
import type { InboundOption } from '@/schemas/client';
|
import type { InboundOption } from '@/schemas/client';
|
||||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||||
|
import { CipherSuitesSelect } from '@/components/form';
|
||||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
@@ -56,6 +57,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
|
|||||||
path: host?.path ?? '',
|
path: host?.path ?? '',
|
||||||
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
|
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
|
||||||
fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
|
fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
|
||||||
|
cipherSuites: host?.cipherSuites ?? '',
|
||||||
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
|
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
|
||||||
keepSniBlank: host?.keepSniBlank ?? false,
|
keepSniBlank: host?.keepSniBlank ?? false,
|
||||||
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
|
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
|
||||||
@@ -332,6 +334,12 @@ export default function HostFormModal({
|
|||||||
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
|
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||||
<Select mode="multiple" allowClear options={alpnOptions} />
|
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name="cipherSuites"
|
||||||
|
label={t('pages.inbounds.form.cipherSuites')}
|
||||||
|
>
|
||||||
|
<CipherSuitesSelect />
|
||||||
|
</FormField>
|
||||||
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -700,6 +700,17 @@ export default function InboundFormModal({
|
|||||||
<InputNumber />
|
<InputNumber />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="excludeFromSub"
|
||||||
|
valueProp="checked"
|
||||||
|
label={labelWithHint(
|
||||||
|
t('pages.inbounds.form.excludeFromSub'),
|
||||||
|
t('pages.inbounds.form.excludeFromSubHelp'),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
{protocol === Protocols.VLESS && (
|
{protocol === Protocols.VLESS && (
|
||||||
<FormField
|
<FormField
|
||||||
name="disableFlow"
|
name="disableFlow"
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ export default function AmneziawgFields({
|
|||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
|
<FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} max={1552} style={{ width: '100%' }} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
|
<FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} max={1608} style={{ width: '100%' }} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
|
<FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
|
||||||
<InputNumber min={0} max={64} style={{ width: '100%' }} />
|
<InputNumber min={0} max={1636} style={{ width: '100%' }} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
|
<FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
|
||||||
<InputNumber min={0} max={32} style={{ width: '100%' }} />
|
<InputNumber min={0} max={32} style={{ width: '100%' }} />
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { CipherSuitesSelect } from '@/components/form';
|
||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField } from '@/components/form/rhf';
|
||||||
import {
|
import {
|
||||||
ALPN_OPTION,
|
ALPN_OPTION,
|
||||||
DOMAIN_STRATEGY_OPTION,
|
DOMAIN_STRATEGY_OPTION,
|
||||||
TLS_CIPHER_OPTION,
|
|
||||||
TLS_VERSION_OPTION,
|
TLS_VERSION_OPTION,
|
||||||
USAGE_OPTION,
|
USAGE_OPTION,
|
||||||
UTLS_FINGERPRINT,
|
UTLS_FINGERPRINT,
|
||||||
@@ -240,12 +240,7 @@ export default function TlsForm({
|
|||||||
name={['streamSettings', 'tlsSettings', 'cipherSuites']}
|
name={['streamSettings', 'tlsSettings', 'cipherSuites']}
|
||||||
label={t('pages.inbounds.form.cipherSuites')}
|
label={t('pages.inbounds.form.cipherSuites')}
|
||||||
>
|
>
|
||||||
<Select
|
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
|
||||||
options={[
|
|
||||||
{ value: '', label: t('pages.inbounds.form.autoOption') },
|
|
||||||
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
|
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
|
||||||
<Space.Compact block>
|
<Space.Compact block>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
.qr-panel-canvas .qr-code {
|
.qr-panel-canvas .qr-code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function QrPanel({
|
|||||||
value={value}
|
value={value}
|
||||||
size={size}
|
size={size}
|
||||||
errorLevel="L"
|
errorLevel="L"
|
||||||
marginSize={4}
|
marginSize={2}
|
||||||
type="svg"
|
type="svg"
|
||||||
bordered={false}
|
bordered={false}
|
||||||
color="#000000"
|
color="#000000"
|
||||||
|
|||||||
@@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Most pushes repeat the previous online sets; handing back a new Map anyway
|
||||||
|
// re-ran the client rollup over every inbound on each traffic event.
|
||||||
|
function sameGuidSets(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean {
|
||||||
|
if (a.size !== b.size) return false;
|
||||||
|
for (const [key, set] of b) {
|
||||||
|
const prev = a.get(key);
|
||||||
|
if (!prev || prev.size !== set.size) return false;
|
||||||
|
for (const value of set) if (!prev.has(value)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
||||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
|
||||||
@@ -440,10 +452,12 @@ export function useInbounds() {
|
|||||||
setOnlineClients(p.onlineClients);
|
setOnlineClients(p.onlineClients);
|
||||||
}
|
}
|
||||||
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
||||||
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
|
const next = toGuidOnlineMap(p.onlineByGuid);
|
||||||
|
setOnlineByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
|
||||||
}
|
}
|
||||||
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
||||||
setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
|
const next = toGuidOnlineMap(p.activeInbounds);
|
||||||
|
setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
|
||||||
}
|
}
|
||||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||||
@@ -537,8 +551,7 @@ export function useInbounds() {
|
|||||||
? stats.map((stat) => {
|
? stats.map((stat) => {
|
||||||
const su = byEmail.get(stat.email);
|
const su = byEmail.get(stat.email);
|
||||||
if (!su) return stat;
|
if (!su) return stat;
|
||||||
statsTouched = true;
|
const merged = {
|
||||||
return {
|
|
||||||
...stat,
|
...stat,
|
||||||
up: typeof su.up === 'number' ? su.up : stat.up,
|
up: typeof su.up === 'number' ? su.up : stat.up,
|
||||||
down: typeof su.down === 'number' ? su.down : stat.down,
|
down: typeof su.down === 'number' ? su.down : stat.down,
|
||||||
@@ -546,9 +559,27 @@ export function useInbounds() {
|
|||||||
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
|
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
|
||||||
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
|
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
|
||||||
} as ClientStats;
|
} as ClientStats;
|
||||||
|
if (
|
||||||
|
merged.up === stat.up &&
|
||||||
|
merged.down === stat.down &&
|
||||||
|
merged.total === stat.total &&
|
||||||
|
merged.expiryTime === stat.expiryTime &&
|
||||||
|
merged.enable === stat.enable
|
||||||
|
) {
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
statsTouched = true;
|
||||||
|
return merged;
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (!upd && !statsTouched) return ib;
|
// Every push lists all inbounds' totals, so only a row whose numbers moved counts.
|
||||||
|
const inboundMoved =
|
||||||
|
!!upd &&
|
||||||
|
((typeof upd.up === 'number' && upd.up !== ib.up) ||
|
||||||
|
(typeof upd.down === 'number' && upd.down !== ib.down) ||
|
||||||
|
(typeof upd.total === 'number' && upd.total !== ib.total) ||
|
||||||
|
(typeof upd.enable === 'boolean' && upd.enable !== ib.enable));
|
||||||
|
if (!inboundMoved && !statsTouched) return ib;
|
||||||
touched = true;
|
touched = true;
|
||||||
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
|
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
|
||||||
if (upd) {
|
if (upd) {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
|
|||||||
<Select
|
<Select
|
||||||
value={rows}
|
value={rows}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 70 }}
|
style={{ width: 100 }}
|
||||||
onChange={setRows}
|
onChange={setRows}
|
||||||
options={[
|
options={[
|
||||||
{ value: '20', label: '20' },
|
{ value: '20', label: '20' },
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
|||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { LazyMount } from '@/components/utility';
|
import { LazyMount } from '@/components/utility';
|
||||||
|
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import OverviewActionBar from './OverviewActionBar';
|
import OverviewActionBar from './OverviewActionBar';
|
||||||
import VitalTile from './VitalTile';
|
import VitalTile from './VitalTile';
|
||||||
@@ -213,6 +214,8 @@ export default function IndexPage() {
|
|||||||
onOpenVersionSwitch={() => setVersionOpen(true)}
|
onOpenVersionSwitch={() => setVersionOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SponsorSlot slot="dashboard" />
|
||||||
|
|
||||||
{health && (
|
{health && (
|
||||||
<div className="ov-health" style={{ color: health.color }}>
|
<div className="ov-health" style={{ color: health.color }}>
|
||||||
<span className="ov-health-mark" />
|
<span className="ov-health-mark" />
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
|
|||||||
<Select
|
<Select
|
||||||
value={rows}
|
value={rows}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 70 }}
|
style={{ width: 100 }}
|
||||||
onChange={setRows}
|
onChange={setRows}
|
||||||
options={[
|
options={[
|
||||||
{ value: '20', label: '20' },
|
{ value: '20', label: '20' },
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export default function OverviewActionBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ov-bar">
|
<div className="ov-bar">
|
||||||
{status.xray.state === 'error' && status.xray.errorMsg ? (
|
{status.xray.errorMsg ? (
|
||||||
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||||
{statePill}
|
{statePill}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -167,6 +167,12 @@ export default function OverviewActionBar({
|
|||||||
statePill
|
statePill
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status.xray.state === 'running' && status.xray.errorMsg ? (
|
||||||
|
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||||
|
<Tag color="error">{t('pages.index.xrayStatusError')}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{updateAvailable ? (
|
{updateAvailable ? (
|
||||||
<Tag
|
<Tag
|
||||||
className="ov-update-tag"
|
className="ov-update-tag"
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
|||||||
<Select
|
<Select
|
||||||
value={rows}
|
value={rows}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 70 }}
|
style={{ width: 100 }}
|
||||||
onChange={setRows}
|
onChange={setRows}
|
||||||
options={[
|
options={[
|
||||||
{ value: '20', label: '20' },
|
{ value: '20', label: '20' },
|
||||||
|
|||||||
@@ -411,3 +411,7 @@
|
|||||||
.submit-row {
|
.submit-row {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-sponsor {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
|||||||
import { HttpUtil, LanguageManager } from '@/utils';
|
import { HttpUtil, LanguageManager } from '@/utils';
|
||||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
|
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
|
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
@@ -247,6 +248,7 @@ export default function LoginPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
<SponsorSlot slot="login" variant="compact" className="login-sponsor" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface ApiMsg<T = unknown> {
|
|||||||
|
|
||||||
const REFRESH_MS = 15000;
|
const REFRESH_MS = 15000;
|
||||||
|
|
||||||
|
const formatKbps = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
||||||
|
|
||||||
export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
|
export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [cpuPoints, setCpuPoints] = useState<number[]>([]);
|
const [cpuPoints, setCpuPoints] = useState<number[]>([]);
|
||||||
@@ -51,7 +53,7 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
|||||||
};
|
};
|
||||||
|
|
||||||
// cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
|
// cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
|
||||||
// as KB/s (no upper clamp, the sparkline auto-scales).
|
// as KB/s, which must opt out of Sparkline's 0-100 "%" defaults.
|
||||||
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
|
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
|
||||||
try {
|
try {
|
||||||
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
|
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
|
||||||
@@ -148,6 +150,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
|||||||
fillOpacity={0.18}
|
fillOpacity={0.18}
|
||||||
markerRadius={2.6}
|
markerRadius={2.6}
|
||||||
showTooltip
|
showTooltip
|
||||||
|
valueMax={null}
|
||||||
|
yFormatter={formatKbps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="series">
|
<div className="series">
|
||||||
@@ -164,6 +168,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
|||||||
fillOpacity={0.18}
|
fillOpacity={0.18}
|
||||||
markerRadius={2.6}
|
markerRadius={2.6}
|
||||||
showTooltip
|
showTooltip
|
||||||
|
valueMax={null}
|
||||||
|
yFormatter={formatKbps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -145,9 +145,12 @@ function formatUptime(secs?: number): string {
|
|||||||
return `${mins}m`;
|
return `${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable per language: the columns memo depends on it, and a fresh function each
|
||||||
|
// render rebuilt every column, re-rendering all rows on each heartbeat push.
|
||||||
function useRelativeTime() {
|
function useRelativeTime() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (unixSeconds?: number) => {
|
return useMemo(
|
||||||
|
() => (unixSeconds?: number) => {
|
||||||
if (!unixSeconds) return t('pages.nodes.never');
|
if (!unixSeconds) return t('pages.nodes.never');
|
||||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||||
@@ -155,7 +158,9 @@ function useRelativeTime() {
|
|||||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||||
return `${Math.floor(diffSec / 86400)}d`;
|
return `${Math.floor(diffSec / 86400)}d`;
|
||||||
};
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NodeList({
|
export default function NodeList({
|
||||||
@@ -530,6 +535,47 @@ export default function NodeList({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// rc-table re-runs every cell renderer whenever the Table re-renders, so keep the
|
||||||
|
// same element until its inputs change rather than re-rendering all rows each time.
|
||||||
|
const nodeTable = useMemo(
|
||||||
|
() => (
|
||||||
|
<Table<NodeRow>
|
||||||
|
dataSource={dataSource}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
size="middle"
|
||||||
|
rowKey="key"
|
||||||
|
rowSelection={
|
||||||
|
dataSource.length > 1
|
||||||
|
? {
|
||||||
|
selectedRowKeys: selectedIds,
|
||||||
|
onChange: (keys) =>
|
||||||
|
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||||
|
getCheckboxProps: (record) => ({
|
||||||
|
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<div className="card-empty">
|
||||||
|
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||||
|
<div>{t('noData')}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||||
|
rowExpandable: (record) => !record.transitive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[dataSource, columns, loading, selectedIds, onSelectionChange, t],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size="small" hoverable>
|
<Card size="small" hoverable>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
@@ -806,39 +852,7 @@ export default function NodeList({
|
|||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Table<NodeRow>
|
nodeTable
|
||||||
dataSource={dataSource}
|
|
||||||
columns={columns}
|
|
||||||
pagination={false}
|
|
||||||
loading={loading}
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
size="middle"
|
|
||||||
rowKey="key"
|
|
||||||
rowSelection={
|
|
||||||
dataSource.length > 1
|
|
||||||
? {
|
|
||||||
selectedRowKeys: selectedIds,
|
|
||||||
onChange: (keys) =>
|
|
||||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
|
||||||
getCheckboxProps: (record) => ({
|
|
||||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
locale={{
|
|
||||||
emptyText: (
|
|
||||||
<div className="card-empty">
|
|
||||||
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
|
||||||
<div>{t('noData')}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
expandable={{
|
|
||||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
|
||||||
rowExpandable: (record) => !record.transitive,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useId, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Input, Modal, Space, Tabs, Typography } from 'antd';
|
||||||
|
|
||||||
|
import JsonEditor from '@/components/form/JsonEditor';
|
||||||
|
import type { HappRoutingProfile } from '@/schemas/happRouting';
|
||||||
|
import {
|
||||||
|
buildHappRoutingDeeplink,
|
||||||
|
loadHappRouting,
|
||||||
|
parseHappRoutingJson,
|
||||||
|
parseHappRoutingList,
|
||||||
|
type HappRoutingListKey,
|
||||||
|
} from './happRoutingEditor';
|
||||||
|
|
||||||
|
interface HappRoutingEditorModalProps {
|
||||||
|
input: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onGenerate: (deeplink: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basicFields: { key: HappRoutingListKey; label: string; placeholder: string }[] = [
|
||||||
|
{
|
||||||
|
key: 'DirectSites',
|
||||||
|
label: 'subHappDirectDomains',
|
||||||
|
placeholder: 'domain:ir\ndomain:cn\nexample.local',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ProxySites',
|
||||||
|
label: 'subHappProxyDomains',
|
||||||
|
placeholder: 'geosite:google\nyoutube.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'BlockSites',
|
||||||
|
label: 'subHappBlockDomains',
|
||||||
|
placeholder: 'geosite:category-ads-all\nanalytics.google.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'DirectIp',
|
||||||
|
label: 'subHappDirectIPs',
|
||||||
|
placeholder: 'geoip:ir\n192.168.0.0/16\n10.0.0.0/8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ProxyIp',
|
||||||
|
label: 'subHappProxyIPs',
|
||||||
|
placeholder: '1.1.1.1/32\n8.8.8.8/32',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'BlockIp',
|
||||||
|
label: 'subHappBlockIPs',
|
||||||
|
placeholder: 'geoip:phishing\n0.0.0.0/8',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type BasicBuffers = Record<HappRoutingListKey, string>;
|
||||||
|
|
||||||
|
function basicBuffers(profile: HappRoutingProfile | null): BasicBuffers {
|
||||||
|
return {
|
||||||
|
DirectSites: profile?.DirectSites?.join('\n') ?? '',
|
||||||
|
ProxySites: profile?.ProxySites?.join('\n') ?? '',
|
||||||
|
BlockSites: profile?.BlockSites?.join('\n') ?? '',
|
||||||
|
DirectIp: profile?.DirectIp?.join('\n') ?? '',
|
||||||
|
ProxyIp: profile?.ProxyIp?.join('\n') ?? '',
|
||||||
|
BlockIp: profile?.BlockIp?.join('\n') ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeBasicRules(profile: HappRoutingProfile, buffers: BasicBuffers): HappRoutingProfile {
|
||||||
|
const next = { ...profile };
|
||||||
|
// Only replace edited lists; absent lists and all other profile fields must survive unchanged.
|
||||||
|
for (const { key } of basicFields) {
|
||||||
|
if (buffers[key] !== (profile[key]?.join('\n') ?? '')) {
|
||||||
|
next[key] = parseHappRoutingList(buffers[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadErrorKeys = {
|
||||||
|
off: 'subHappEditorLoadOff',
|
||||||
|
remote: 'subHappEditorLoadRemote',
|
||||||
|
invalid: 'subHappEditorLoadInvalid',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HappRoutingEditorModal({
|
||||||
|
input,
|
||||||
|
onCancel,
|
||||||
|
onGenerate,
|
||||||
|
}: HappRoutingEditorModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fieldId = useId();
|
||||||
|
const [loaded] = useState(() => loadHappRouting(input));
|
||||||
|
const [profile, setProfile] = useState(() => (loaded.success ? loaded.profile : null));
|
||||||
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
|
// Keep raw buffers while typing so trailing newlines and temporarily invalid JSON are not lost.
|
||||||
|
const [buffers, setBuffers] = useState(() => basicBuffers(profile));
|
||||||
|
const [jsonText, setJsonText] = useState(() => (profile ? JSON.stringify(profile, null, 2) : ''));
|
||||||
|
const advancedProfile = activeTab === 'advanced' ? parseHappRoutingJson(jsonText) : null;
|
||||||
|
const invalidJson = activeTab === 'advanced' && advancedProfile === null;
|
||||||
|
|
||||||
|
const switchTab = (nextTab: string) => {
|
||||||
|
if (!profile || nextTab === activeTab) return;
|
||||||
|
if (nextTab === 'advanced') {
|
||||||
|
const next = mergeBasicRules(profile, buffers);
|
||||||
|
setProfile(next);
|
||||||
|
setJsonText(JSON.stringify(next, null, 2));
|
||||||
|
} else {
|
||||||
|
if (!advancedProfile) return;
|
||||||
|
setProfile(advancedProfile);
|
||||||
|
setBuffers(basicBuffers(advancedProfile));
|
||||||
|
}
|
||||||
|
setActiveTab(nextTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generate = () => {
|
||||||
|
if (!loaded.success || !profile) return;
|
||||||
|
const next = activeTab === 'advanced' ? advancedProfile : mergeBasicRules(profile, buffers);
|
||||||
|
if (next) onGenerate(buildHappRoutingDeeplink(next, loaded.mode));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('pages.settings.subHappModalTitle')}
|
||||||
|
open
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={generate}
|
||||||
|
okText={t('pages.settings.subHappBuildDeeplink')}
|
||||||
|
okButtonProps={{ disabled: !loaded.success || invalidJson }}
|
||||||
|
width={650}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
|
||||||
|
{!loaded.success ? (
|
||||||
|
<Alert type="error" showIcon title={t(`pages.settings.${loadErrorKeys[loaded.error]}`)} />
|
||||||
|
) : loaded.isNew ? (
|
||||||
|
<Alert type="info" showIcon title={t('pages.settings.subHappEditorNew')} />
|
||||||
|
) : null}
|
||||||
|
{loaded.success ? (
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={switchTab}
|
||||||
|
destroyOnHidden
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'basic',
|
||||||
|
label: t('pages.settings.subHappEditorBasic'),
|
||||||
|
disabled: invalidJson,
|
||||||
|
children: (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Typography.Text type="secondary" id={`${fieldId}-hint`}>
|
||||||
|
{t('pages.settings.subHappEditorListHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
{basicFields.map(({ key, label, placeholder }) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label
|
||||||
|
htmlFor={`${fieldId}-${key}`}
|
||||||
|
style={{ display: 'block', fontWeight: 600, marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
{t(`pages.settings.${label}`)}
|
||||||
|
</label>
|
||||||
|
<Input.TextArea
|
||||||
|
id={`${fieldId}-${key}`}
|
||||||
|
aria-describedby={`${fieldId}-hint`}
|
||||||
|
rows={2}
|
||||||
|
value={buffers[key]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBuffers((previous) => ({ ...previous, [key]: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'advanced',
|
||||||
|
label: t('pages.settings.subHappEditorAdvanced'),
|
||||||
|
children: (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.subHappEditorAdvancedHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
{invalidJson ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
title={t('pages.settings.subHappEditorInvalidJson')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<JsonEditor
|
||||||
|
value={jsonText}
|
||||||
|
onChange={setJsonText}
|
||||||
|
minHeight="320px"
|
||||||
|
maxHeight="50vh"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Input, Modal, Select, Space, Switch, Tabs, message } from 'antd';
|
import { Button, Input, Select, Space, Switch, Tabs, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
BranchesOutlined,
|
BranchesOutlined,
|
||||||
BuildOutlined,
|
BuildOutlined,
|
||||||
CloudSyncOutlined,
|
CloudSyncOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
LinkOutlined,
|
|
||||||
MobileOutlined,
|
|
||||||
NotificationOutlined,
|
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets';
|
import { buildHappPresetDeeplink } from './happPresets';
|
||||||
|
import HappRoutingEditorModal from './HappRoutingEditorModal';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
|
|
||||||
interface HappSettingsContentProps {
|
interface HappSettingsContentProps {
|
||||||
@@ -21,7 +19,6 @@ interface HappSettingsContentProps {
|
|||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
remoteSourceBadge: (val: string) => React.ReactNode;
|
remoteSourceBadge: (val: string) => React.ReactNode;
|
||||||
defaultActiveTab?: 'routing' | 'links';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HappSettingsContent({
|
export default function HappSettingsContent({
|
||||||
@@ -29,41 +26,22 @@ export default function HappSettingsContent({
|
|||||||
updateSetting,
|
updateSetting,
|
||||||
isMobile,
|
isMobile,
|
||||||
remoteSourceBadge,
|
remoteSourceBadge,
|
||||||
defaultActiveTab = 'routing',
|
|
||||||
}: HappSettingsContentProps) {
|
}: HappSettingsContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Generator choices stay local until Apply updates the draft; page Save persists it.
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
|
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
|
||||||
|
const [includeAdblock, setIncludeAdblock] = useState(false);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
const [directDomains, setDirectDomains] = useState('');
|
|
||||||
const [proxyDomains, setProxyDomains] = useState('');
|
|
||||||
const [blockDomains, setBlockDomains] = useState('');
|
|
||||||
const [directIPs, setDirectIPs] = useState('');
|
|
||||||
const [proxyIPs, setProxyIPs] = useState('');
|
|
||||||
const [blockIPs, setBlockIPs] = useState('');
|
|
||||||
|
|
||||||
const applyPreset = () => {
|
const applyPreset = () => {
|
||||||
const payload = buildHappPresetDeeplink(selectedPreset);
|
const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
|
||||||
if (payload) {
|
if (payload) {
|
||||||
updateSetting({ subRoutingRules: payload });
|
updateSetting({ subRoutingRules: payload });
|
||||||
message.success(t('pages.settings.subHappPresetApplied'));
|
message.success(t('pages.settings.subHappPresetApplied'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBuildDeeplink = () => {
|
const handleBuildDeeplink = (deeplink: string) => {
|
||||||
const profile = {
|
|
||||||
Name: 'Custom Rules',
|
|
||||||
GlobalProxy: 'true',
|
|
||||||
DirectSites: parseList(directDomains),
|
|
||||||
DirectIp: parseList(directIPs),
|
|
||||||
ProxySites: parseList(proxyDomains),
|
|
||||||
ProxyIp: parseList(proxyIPs),
|
|
||||||
BlockSites: parseList(blockDomains),
|
|
||||||
BlockIp: parseList(blockIPs),
|
|
||||||
DomainStrategy: 'IPIfNonMatch',
|
|
||||||
};
|
|
||||||
|
|
||||||
const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile));
|
|
||||||
updateSetting({ subRoutingRules: deeplink });
|
updateSetting({ subRoutingRules: deeplink });
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
message.success(t('pages.settings.subHappDeeplinkGenerated'));
|
message.success(t('pages.settings.subHappDeeplinkGenerated'));
|
||||||
@@ -82,17 +60,27 @@ export default function HappSettingsContent({
|
|||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.happLinkEnable')}
|
||||||
|
description={t('pages.settings.happLinkEnableDesc')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={allSetting.happLinkEnable}
|
||||||
|
onChange={(v) => updateSetting({ happLinkEnable: v })}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
type="card"
|
type="card"
|
||||||
size="small"
|
size="small"
|
||||||
defaultActiveKey={defaultActiveTab}
|
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'routing',
|
key: 'routing',
|
||||||
label: (
|
label: catTabLabel(
|
||||||
<span>
|
<BranchesOutlined />,
|
||||||
<BranchesOutlined /> {!isMobile && t('pages.settings.subHappGroupRouting')}
|
t('pages.settings.subHappGroupRouting'),
|
||||||
</span>
|
isMobile,
|
||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
@@ -112,21 +100,31 @@ export default function HappSettingsContent({
|
|||||||
title={t('pages.settings.subHappPresets')}
|
title={t('pages.settings.subHappPresets')}
|
||||||
description={t('pages.settings.subHappPresetsDesc')}
|
description={t('pages.settings.subHappPresetsDesc')}
|
||||||
>
|
>
|
||||||
<Space orientation="horizontal" style={{ width: '100%' }}>
|
<Space orientation="horizontal" wrap style={{ width: '100%' }}>
|
||||||
<Select
|
<Select
|
||||||
|
aria-label={t('pages.settings.subHappPresets')}
|
||||||
value={selectedPreset}
|
value={selectedPreset}
|
||||||
style={{ minWidth: 170 }}
|
style={{ minWidth: 170 }}
|
||||||
onChange={setSelectedPreset}
|
onChange={setSelectedPreset}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
|
{ value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
|
||||||
{ value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
|
{ value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
|
||||||
{ value: 'adblock', label: t('pages.settings.subHappPresetAdblock') },
|
|
||||||
{ value: 'global', label: t('pages.settings.subHappPresetGlobal') },
|
{ value: 'global', label: t('pages.settings.subHappPresetGlobal') },
|
||||||
|
{ value: 'lan-bypass', label: t('pages.settings.subHappPresetLocal') },
|
||||||
{ value: 'off', label: t('pages.settings.subHappPresetOff') },
|
{ value: 'off', label: t('pages.settings.subHappPresetOff') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Space size="small">
|
||||||
|
<Switch
|
||||||
|
aria-label={t('pages.settings.subHappIncludeAdblock')}
|
||||||
|
checked={includeAdblock}
|
||||||
|
disabled={selectedPreset === 'off'}
|
||||||
|
onChange={setIncludeAdblock}
|
||||||
|
/>
|
||||||
|
<span>{t('pages.settings.subHappIncludeAdblock')}</span>
|
||||||
|
</Space>
|
||||||
<Button type="primary" onClick={applyPreset}>
|
<Button type="primary" onClick={applyPreset}>
|
||||||
{t('pages.settings.subHappPresets')}
|
{t('pages.settings.subHappApplyPreset')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -180,27 +178,11 @@ export default function HappSettingsContent({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'links',
|
key: 'appearance',
|
||||||
label: catTabLabel(<LinkOutlined />, t('pages.settings.subHappGroupLinks'), isMobile),
|
label: catTabLabel(
|
||||||
children: (
|
<DesktopOutlined />,
|
||||||
<SettingListItem
|
t('pages.settings.subHappGroupThemes'),
|
||||||
paddings="small"
|
isMobile,
|
||||||
title={t('pages.settings.happLinkEnable')}
|
|
||||||
description={t('pages.settings.happLinkEnableDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.happLinkEnable}
|
|
||||||
onChange={(v) => updateSetting({ happLinkEnable: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'banners',
|
|
||||||
label: (
|
|
||||||
<span>
|
|
||||||
<NotificationOutlined /> {!isMobile && t('pages.settings.subHappGroupBanners')}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
@@ -292,15 +274,59 @@ export default function HappSettingsContent({
|
|||||||
onChange={(v) => updateSetting({ subHappNotificationExpire: v })}
|
onChange={(v) => updateSetting({ subHappNotificationExpire: v })}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subHappColorProfile')}
|
||||||
|
description={t('pages.settings.subHappColorProfileDesc')}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
value={allSetting.subHappColorProfile}
|
||||||
|
placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
|
||||||
|
onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Space wrap size="small">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => updateSetting({ subHappColorProfile: 'resetcolors' })}
|
||||||
|
>
|
||||||
|
{t('reset')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
updateSetting({
|
||||||
|
subHappColorProfile:
|
||||||
|
'{"serverRowBackgroundColor":"#21003D67","cardBackgroundColor":"#120023B3"}',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Violet
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
updateSetting({
|
||||||
|
subHappColorProfile:
|
||||||
|
'{"serverRowBackgroundColor":"#002B3667","cardBackgroundColor":"#001F27B3"}',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Turquoise
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</SettingListItem>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'network',
|
key: 'network',
|
||||||
label: (
|
label: catTabLabel(
|
||||||
<span>
|
<ThunderboltOutlined />,
|
||||||
<ThunderboltOutlined /> {!isMobile && t('pages.settings.subHappGroupNetwork')}
|
t('pages.settings.subHappGroupNetwork'),
|
||||||
</span>
|
isMobile,
|
||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
@@ -382,6 +408,23 @@ export default function HappSettingsContent({
|
|||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subHappLocalProxyAuth')}
|
||||||
|
description={t('pages.settings.subHappLocalProxyAuthDesc')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={allSetting.subHappLocalProxyAuth ?? 'auto'}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(v) => updateSetting({ subHappLocalProxyAuth: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'auto', label: t('pages.settings.subHappLocalProxyAuthAuto') },
|
||||||
|
{ value: 'disable', label: t('pages.settings.subHappLocalProxyAuthDisable') },
|
||||||
|
{ value: '', label: t('pages.settings.subHappLocalProxyAuthUnset') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.subHappAutoConnect')}
|
title={t('pages.settings.subHappAutoConnect')}
|
||||||
@@ -412,70 +455,45 @@ export default function HappSettingsContent({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'themes',
|
|
||||||
label: (
|
|
||||||
<span>
|
|
||||||
<DesktopOutlined /> {!isMobile && t('pages.settings.subHappGroupThemes')}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.subHappColorProfile')}
|
title={t('pages.settings.subHappPerAppMode')}
|
||||||
description={t('pages.settings.subHappColorProfileDesc')}
|
description={t('pages.settings.subHappPerAppModeDesc')}
|
||||||
>
|
>
|
||||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
<Select
|
||||||
<Input
|
value={allSetting.subHappPerAppMode || 'off'}
|
||||||
value={allSetting.subHappColorProfile}
|
style={{ width: '100%' }}
|
||||||
placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
|
onChange={(v) => updateSetting({ subHappPerAppMode: v })}
|
||||||
onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
|
options={[
|
||||||
|
{ value: 'off', label: t('pages.settings.subHappPerAppOff') },
|
||||||
|
{ value: 'on', label: t('pages.settings.subHappPerAppOn') },
|
||||||
|
{ value: 'bypass', label: t('pages.settings.subHappPerAppBypass') },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Space wrap size="small">
|
</SettingListItem>
|
||||||
<Button
|
|
||||||
size="small"
|
<SettingListItem
|
||||||
onClick={() => updateSetting({ subHappColorProfile: 'resetcolors' })}
|
paddings="small"
|
||||||
|
title={t('pages.settings.subHappPerAppList')}
|
||||||
|
description={t('pages.settings.subHappPerAppListDesc')}
|
||||||
>
|
>
|
||||||
{t('reset')}
|
<Input.TextArea
|
||||||
</Button>
|
value={allSetting.subHappPerAppList}
|
||||||
<Button
|
rows={4}
|
||||||
size="small"
|
placeholder="org.telegram.messenger, com.google.android.youtube"
|
||||||
onClick={() =>
|
onChange={(e) => updateSetting({ subHappPerAppList: e.target.value })}
|
||||||
updateSetting({
|
/>
|
||||||
subHappColorProfile:
|
|
||||||
'{"serverRowBackgroundColor":"#21003D67","cardBackgroundColor":"#120023B3"}',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Violet
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
updateSetting({
|
|
||||||
subHappColorProfile:
|
|
||||||
'{"serverRowBackgroundColor":"#002B3667","cardBackgroundColor":"#001F27B3"}',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Turquoise
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'failover',
|
key: 'failover',
|
||||||
label: (
|
label: catTabLabel(
|
||||||
<span>
|
<CloudSyncOutlined />,
|
||||||
<CloudSyncOutlined /> {!isMobile && t('pages.settings.subHappGroupFailover')}
|
t('pages.settings.subHappGroupFailover'),
|
||||||
</span>
|
isMobile,
|
||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
@@ -528,127 +546,17 @@ export default function HappSettingsContent({
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'android',
|
|
||||||
label: (
|
|
||||||
<span>
|
|
||||||
<MobileOutlined /> {!isMobile && t('pages.settings.subHappGroupAndroid')}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subHappPerAppMode')}
|
|
||||||
description={t('pages.settings.subHappPerAppModeDesc')}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={allSetting.subHappPerAppMode || 'off'}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={(v) => updateSetting({ subHappPerAppMode: v })}
|
|
||||||
options={[
|
|
||||||
{ value: 'off', label: t('pages.settings.subHappPerAppOff') },
|
|
||||||
{ value: 'on', label: t('pages.settings.subHappPerAppOn') },
|
|
||||||
{ value: 'bypass', label: t('pages.settings.subHappPerAppBypass') },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subHappPerAppList')}
|
|
||||||
description={t('pages.settings.subHappPerAppListDesc')}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
value={allSetting.subHappPerAppList}
|
|
||||||
rows={4}
|
|
||||||
placeholder="org.telegram.messenger, com.google.android.youtube"
|
|
||||||
onChange={(e) => updateSetting({ subHappPerAppList: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
{/* Mount per opening so canceled edits are discarded and the latest parent draft is loaded. */}
|
||||||
title={t('pages.settings.subHappModalTitle')}
|
{isModalOpen ? (
|
||||||
open={isModalOpen}
|
<HappRoutingEditorModal
|
||||||
|
input={allSetting.subRoutingRules}
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
onOk={handleBuildDeeplink}
|
onGenerate={handleBuildDeeplink}
|
||||||
okText={t('pages.settings.subHappBuildDeeplink')}
|
|
||||||
width={650}
|
|
||||||
>
|
|
||||||
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
{t('pages.settings.subHappDirectDomains')}
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
value={directDomains}
|
|
||||||
placeholder="domain:ir, domain:cn, example.local"
|
|
||||||
onChange={(e) => setDirectDomains(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : null}
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
{t('pages.settings.subHappProxyDomains')}
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
value={proxyDomains}
|
|
||||||
placeholder="geosite:google, youtube.com"
|
|
||||||
onChange={(e) => setProxyDomains(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
{t('pages.settings.subHappBlockDomains')}
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
value={blockDomains}
|
|
||||||
placeholder="geosite:category-ads-all, analytics.google.com"
|
|
||||||
onChange={(e) => setBlockDomains(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
{t('pages.settings.subHappDirectIPs')}
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
value={directIPs}
|
|
||||||
placeholder="geoip:ir, 192.168.0.0/16, 10.0.0.0/8"
|
|
||||||
onChange={(e) => setDirectIPs(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
{t('pages.settings.subHappProxyIPs')}
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
value={proxyIPs}
|
|
||||||
placeholder="1.1.1.1/32, 8.8.8.8/32"
|
|
||||||
onChange={(e) => setProxyIPs(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
{t('pages.settings.subHappBlockIPs')}
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
value={blockIPs}
|
|
||||||
placeholder="geoip:phishing, 0.0.0.0/8"
|
|
||||||
onChange={(e) => setBlockIPs(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user