mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 01:32:08 +03:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -83,12 +83,12 @@ jobs:
|
||||
- name: PostgreSQL schema and migration tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
|
||||
# Both must pass. Counting, not SKIP-matching: renaming either test would
|
||||
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
|
||||
# All must pass. Counting, not SKIP-matching: renaming a test would
|
||||
# otherwise leave this step green while testing nothing.
|
||||
passed=$(grep -c -- '--- PASS' /tmp/postgres-schema.log || true)
|
||||
if [ "$passed" -lt 2 ]; then
|
||||
echo "expected 2 passing PostgreSQL schema tests, got $passed" >&2
|
||||
passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
|
||||
if [ "$passed" -lt 3 ]; then
|
||||
echo "expected at least 3 passing PostgreSQL schema tests, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ jobs:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_args: |
|
||||
--model claude-opus-5
|
||||
--effort xhigh
|
||||
--model claude-opus-5-5
|
||||
--effort medium
|
||||
--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/**)"
|
||||
--disallowedTools "Read(//**/.git/**),Edit(//**/.git/**)"
|
||||
@@ -437,8 +437,24 @@ jobs:
|
||||
path: ${{ runner.temp }}/claude-execution-output.json
|
||||
if-no-files-found: ignore
|
||||
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() }}
|
||||
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:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
|
||||
@@ -118,8 +118,8 @@ jobs:
|
||||
# allowedTools only pre-approves; it denies nothing. Only the deny list
|
||||
# stops the review executing what it just checked out, or delegating.
|
||||
claude_args: |
|
||||
--model claude-opus-5
|
||||
--effort xhigh
|
||||
--model claude-opus-5-5
|
||||
--effort medium
|
||||
--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"
|
||||
--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 "::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\`."
|
||||
# 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.
|
||||
# --paginate prints one jq count per page, so the pages are summed.
|
||||
- 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:
|
||||
HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
|
||||
STARTED_AT: ${{ steps.started.outputs.at }}
|
||||
|
||||
@@ -75,11 +75,18 @@ file locations when it can answer in one hop.
|
||||
share-link or install-command output changes.
|
||||
|
||||
## Hard rules (non-negotiable)
|
||||
- Fix size must match bug size. Find the root cause, then make the SMALLEST
|
||||
change that removes it — a one-line guard beats a new subsystem. A small bug
|
||||
does not earn new columns, jobs, abstractions, config knobs or helper layers.
|
||||
If a fix genuinely needs new architecture, say so and get agreement first;
|
||||
never ship it unasked next to the fix.
|
||||
- Correct fix over small fix. Find the root cause and fix it the right way, however
|
||||
much code that takes. Size the change by what the correct fix needs, never by
|
||||
line count: when the right fix spans many files, or needs a migration, a shared
|
||||
helper or a new abstraction, write it. A guard that hides the symptom while the
|
||||
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
|
||||
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
|
||||
@@ -113,6 +120,36 @@ file locations when it can answer in one hop.
|
||||
explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
|
||||
`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
|
||||
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
|
||||
`t.Helper()` on helpers. Assert the exact value / typed error / emitted
|
||||
@@ -120,12 +157,6 @@ file locations when it can answer in one hop.
|
||||
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
|
||||
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
|
||||
`internal/sub`'s `initSubDB(t)` is the template.
|
||||
- A test must fail without its fix. Write it, revert the fix, watch it go red,
|
||||
restore. A test that passes either way is worse than no test: it certifies
|
||||
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`.
|
||||
- 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
|
||||
@@ -136,7 +167,7 @@ file locations when it can answer in one hop.
|
||||
- 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
|
||||
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
|
||||
a headless-Chromium Storybook project, so run
|
||||
`npx playwright install --with-deps chromium` once or `make verify` fails.
|
||||
|
||||
+7
-1
@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
|
||||
## Prerequisites
|
||||
|
||||
- **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**
|
||||
- **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,6 +243,12 @@ 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/`.
|
||||
|
||||
### 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
|
||||
|
||||
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
**Frontend (`cd frontend`, Node 24 — see `.nvmrc`):**
|
||||
**Frontend (`cd frontend`, Node 26 — see `.nvmrc`):**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
|
||||
@@ -43,10 +43,10 @@ value defeats the point, since DPI can fingerprint it over time.
|
||||
| ------------ | ---------------------------------------------------------------------------- |
|
||||
| **Jc** | Number of junk packets sent before the handshake. |
|
||||
| **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. |
|
||||
| **S3** | Cookie-reply padding, `0`-`64`. |
|
||||
| **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`-`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`. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
@@ -20,7 +20,7 @@ inbounds** at once, with per-client traffic accounting.
|
||||
| **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). |
|
||||
| **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.|
|
||||
| **Sub ID** | all | Subscription identifier grouping this client's links. |
|
||||
| **Group** | all | Optional client group for organization and bulk filtering. |
|
||||
@@ -42,6 +42,68 @@ inbounds** at once, with per-client traffic accounting.
|
||||
- **Online status** and **last-online** times are tracked per client (and per
|
||||
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
|
||||
|
||||
Every client has share links and a QR code for its inbounds, plus a combined
|
||||
|
||||
@@ -22,6 +22,12 @@ _openapi:
|
||||
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); expired entries 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-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
|
||||
- depth: 2
|
||||
title: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||
decide whether to show the OTP field.
|
||||
@@ -39,6 +45,11 @@ _openapi:
|
||||
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); expired entries 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-expired-entries-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
|
||||
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 (
|
||||
<>
|
||||
{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
|
||||
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
|
||||
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
|
||||
@@ -317,6 +320,8 @@ _openapi:
|
||||
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
|
||||
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
|
||||
@@ -592,6 +597,16 @@ _openapi:
|
||||
one per line. `limitHwid` is applied only when every inbound
|
||||
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
|
||||
- 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
|
||||
fails no longer stops the others. Every inbound error names the
|
||||
inbound it came from (`inbound 7: <message>`), and several failures
|
||||
@@ -638,7 +653,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ icon: Users
|
||||
| **Limit IP** | 全部(TUIC 除外) | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。 |
|
||||
| **Total (GB)** | 全部(TUIC 除外) | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
|
||||
| **Expiry** | 全部 | 该日期之后客户端停止工作。 |
|
||||
| **Reset** | 全部 | 以**天**为单位的自动续期周期(滚动重置配额)。 |
|
||||
| **自动续期** | 全部 | 关闭、固定天数、日历每周或日历每月。 |
|
||||
| **Telegram ID**| 全部 | 将客户端关联到 Telegram 用户,用于自助服务/通知。 |
|
||||
| **Sub ID** | 全部 | 用于对该客户端链接分组的订阅标识符。 |
|
||||
| **Group** | 全部 | 可选的客户端分组,便于组织管理和批量筛选。 |
|
||||
@@ -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>
|
||||
|
||||
## 分享链接与外部链接
|
||||
|
||||
每个客户端都有针对其各入站的分享链接和二维码,外加一个合并的
|
||||
|
||||
+11
-11
@@ -18,14 +18,14 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"fumadocs-core": "^16.15.11",
|
||||
"fumadocs-core": "^16.15.14",
|
||||
"fumadocs-docgen": "^3.1.1",
|
||||
"fumadocs-mdx": "^15.4.1",
|
||||
"fumadocs-openapi": "^11.4.3",
|
||||
"fumadocs-ui": "^16.15.11",
|
||||
"lucide-react": "^1.46.0",
|
||||
"fumadocs-mdx": "^15.4.5",
|
||||
"fumadocs-openapi": "^12.0.3",
|
||||
"fumadocs-ui": "^16.15.14",
|
||||
"lucide-react": "^1.48.0",
|
||||
"mermaid": "^12.0.0",
|
||||
"next": "16.3.5",
|
||||
"next": "16.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
@@ -37,15 +37,15 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"oxfmt": "0.68.0",
|
||||
"oxlint": "1.83.0",
|
||||
"oxfmt": "0.70.0",
|
||||
"oxlint": "1.85.0",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^5.0.1"
|
||||
"vitest": "^5.0.2"
|
||||
},
|
||||
"packageManager": "pnpm@12.4.2"
|
||||
"packageManager": "pnpm@12.6.0"
|
||||
}
|
||||
|
||||
Generated
+497
-484
File diff suppressed because it is too large
Load Diff
@@ -12,16 +12,16 @@ minimumReleaseAgeExclude:
|
||||
- mermaid@11.17.0
|
||||
- lucide-react@1.33.0
|
||||
- postcss@8.5.26
|
||||
- fumadocs-mdx@15.3.0 || 15.4.1
|
||||
- fumadocs-mdx@15.3.0 || 15.4.1 || 15.4.5
|
||||
- '@fumadocs/api-docs@0.2.7 || 0.2.9'
|
||||
- '@types/node@26.4.1'
|
||||
- fumadocs-core@16.15.5 || 16.15.11
|
||||
- fumadocs-openapi@11.4.0 || 11.4.3
|
||||
- fumadocs-openapi@11.4.0 || 11.4.3 || 12.0.3
|
||||
- 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'
|
||||
- '@vitest/spy@5.0.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
|
||||
- vitest@5.0.1 || 5.0.2
|
||||
|
||||
+371
-2
@@ -69,6 +69,9 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -288,6 +291,9 @@
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -519,6 +525,7 @@
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -586,6 +593,7 @@
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -700,6 +708,9 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -943,6 +954,9 @@
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1174,6 +1188,7 @@
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -1249,6 +1264,7 @@
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -1523,13 +1539,17 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
||||
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"description": "Max auto-renew count, 0 = unlimited",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1592,6 +1612,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"security",
|
||||
"subId",
|
||||
"tgId",
|
||||
@@ -1743,6 +1764,9 @@
|
||||
"resetMax": {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
@@ -1798,6 +1822,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"reverse",
|
||||
"secret",
|
||||
"security",
|
||||
@@ -1811,6 +1836,97 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"tag": {
|
||||
@@ -1881,6 +1997,10 @@
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "abcd1234",
|
||||
"type": "string"
|
||||
@@ -1915,6 +2035,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"totalGB",
|
||||
"updatedAt"
|
||||
@@ -1970,7 +2091,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"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,
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1979,6 +2100,11 @@
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "i7tvdpeffi0hvvf1",
|
||||
"type": "string"
|
||||
@@ -2011,6 +2137,7 @@
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"total",
|
||||
"up",
|
||||
@@ -2301,6 +2428,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2434,6 +2564,7 @@
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
@@ -2478,6 +2609,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2604,6 +2738,7 @@
|
||||
"required": [
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
@@ -4127,6 +4262,84 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Sponsor": {
|
||||
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"example": true,
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"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": {
|
||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||
"properties": {
|
||||
@@ -4579,6 +4792,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sponsors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries 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,
|
||||
"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": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -4660,6 +4926,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -7894,6 +8161,7 @@
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
@@ -8086,6 +8354,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}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10145,6 +10504,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -11268,6 +11628,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11362,6 +11723,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11459,6 +11821,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11609,6 +11972,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11730,6 +12094,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11981,6 +12346,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -15512,6 +15878,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -15594,6 +15961,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -15640,6 +16008,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
|
||||
@@ -26,7 +26,9 @@ export const withTheme: Decorator = (Story, context) => {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}, [dark]);
|
||||
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 }}>
|
||||
<Story />
|
||||
</div>
|
||||
|
||||
Generated
+1164
-830
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -5,8 +5,8 @@
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
"node": ">=26.0.0",
|
||||
"npm": ">=11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -39,9 +39,9 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"@tanstack/react-query-devtools": "^5.102.8",
|
||||
"antd": "^6.6.4",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"@tanstack/react-query-devtools": "^5.103.2",
|
||||
"antd": "^6.6.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"i18next": "^26.4.2",
|
||||
@@ -50,9 +50,9 @@
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "^17.0.14",
|
||||
"react-router": "^8.3.1",
|
||||
"swagger-ui-react": "^5.32.15",
|
||||
"react-i18next": "^17.0.15",
|
||||
"react-router": "^8.4.0",
|
||||
"swagger-ui-react": "^5.33.0",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
@@ -67,20 +67,20 @@
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"@vitest/browser-playwright": "5.0.2",
|
||||
"@vitest/coverage-v8": "^5.0.2",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsdom": "^30.1.1",
|
||||
"lint-staged": "^17.5.1",
|
||||
"msw": "^2.15.0",
|
||||
"oxfmt": "0.68.0",
|
||||
"oxlint": "1.83.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"oxfmt": "0.70.0",
|
||||
"oxlint": "1.85.0",
|
||||
"oxlint-tsgolint": "^7.0.2003",
|
||||
"playwright": "^1.63.0",
|
||||
"storybook": "^10.6.0",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "^5.0.0"
|
||||
"vite": "8.3.1",
|
||||
"vitest": "^5.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "^3.4.11",
|
||||
@@ -98,8 +98,8 @@
|
||||
},
|
||||
"@storybook/addon-vitest": {
|
||||
"vitest": "^5.0.0",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/browser": "5.0.0"
|
||||
"@vitest/browser-playwright": "^5.0.0",
|
||||
"@vitest/browser": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -288,6 +291,9 @@
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -519,6 +525,7 @@
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -586,6 +593,7 @@
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -700,6 +708,9 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -943,6 +954,9 @@
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1174,6 +1188,7 @@
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -1249,6 +1264,7 @@
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -1523,13 +1539,17 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
||||
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"description": "Max auto-renew count, 0 = unlimited",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1592,6 +1612,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"security",
|
||||
"subId",
|
||||
"tgId",
|
||||
@@ -1743,6 +1764,9 @@
|
||||
"resetMax": {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
@@ -1798,6 +1822,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"reverse",
|
||||
"secret",
|
||||
"security",
|
||||
@@ -1811,6 +1836,97 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"tag": {
|
||||
@@ -1881,6 +1997,10 @@
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "abcd1234",
|
||||
"type": "string"
|
||||
@@ -1915,6 +2035,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"totalGB",
|
||||
"updatedAt"
|
||||
@@ -1970,7 +2091,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"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,
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1979,6 +2100,11 @@
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "i7tvdpeffi0hvvf1",
|
||||
"type": "string"
|
||||
@@ -2011,6 +2137,7 @@
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"total",
|
||||
"up",
|
||||
@@ -2301,6 +2428,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2434,6 +2564,7 @@
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
@@ -2478,6 +2609,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2604,6 +2738,7 @@
|
||||
"required": [
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
@@ -4127,6 +4262,84 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Sponsor": {
|
||||
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"example": true,
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"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": {
|
||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||
"properties": {
|
||||
@@ -4579,6 +4792,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sponsors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries 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,
|
||||
"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": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -4660,6 +4926,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -7894,6 +8161,7 @@
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
@@ -8086,6 +8354,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}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10145,6 +10504,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -11268,6 +11628,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11362,6 +11723,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11459,6 +11821,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11609,6 +11972,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11730,6 +12094,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11981,6 +12346,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -15512,6 +15878,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -15594,6 +15961,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -15640,6 +16008,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
|
||||
@@ -196,6 +196,10 @@ export async function httpRequest(
|
||||
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
|
||||
}
|
||||
|
||||
export function withBasePath(path: string): string {
|
||||
return basePathPrefix + path;
|
||||
}
|
||||
|
||||
export function setupHttp(): void {
|
||||
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
||||
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 = {
|
||||
sponsors: () => ['sponsors'] as const,
|
||||
server: {
|
||||
status: () => ['server', 'status'] as const,
|
||||
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DiscordOutlined,
|
||||
@@ -418,6 +419,12 @@ export default function CommandPalette() {
|
||||
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||
icon: <ApiOutlined />,
|
||||
},
|
||||
{
|
||||
path: '/sponsors',
|
||||
title: t('menu.sponsors'),
|
||||
keywords: ['sponsors', 'sponsor', 'partners'],
|
||||
icon: <CrownOutlined />,
|
||||
},
|
||||
];
|
||||
|
||||
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 GoRegexInput, validateGoRegex } from './GoRegexInput';
|
||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||
export { default as CipherSuitesSelect } from './CipherSuitesSelect';
|
||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||
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';
|
||||
|
||||
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 ENTRY_FILTER_DELAY = 500;
|
||||
|
||||
@@ -224,7 +228,12 @@ export default function GeoBrowserModal({
|
||||
[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',
|
||||
@@ -391,9 +400,9 @@ export default function GeoBrowserModal({
|
||||
<Table
|
||||
size="small"
|
||||
showHeader={false}
|
||||
rowKey={(entry, index) => `${entry.value}-${index}`}
|
||||
rowKey="position"
|
||||
columns={entryColumns}
|
||||
dataSource={entriesQuery.data?.items ?? []}
|
||||
dataSource={entryRows}
|
||||
loading={entriesQuery.isLoading}
|
||||
locale={{
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"discordMemory": 0,
|
||||
"discordRunTime": "",
|
||||
"expireDiff": 0,
|
||||
"externalSubUserAgent": "",
|
||||
"externalTrafficInformEnable": false,
|
||||
"externalTrafficInformURI": "",
|
||||
"happLinkEnable": false,
|
||||
@@ -80,6 +81,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHappExcludeApns": false,
|
||||
"subHappExcludeRoutes": "",
|
||||
"subHappFallbackUrl": "",
|
||||
"subHappLocalProxyAuth": "",
|
||||
"subHappNewUrl": "",
|
||||
"subHappNoLimit": false,
|
||||
"subHappNotificationExpire": false,
|
||||
@@ -162,6 +164,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"discordMemory": 0,
|
||||
"discordRunTime": "",
|
||||
"expireDiff": 0,
|
||||
"externalSubUserAgent": "",
|
||||
"externalTrafficInformEnable": false,
|
||||
"externalTrafficInformURI": "",
|
||||
"happLinkEnable": false,
|
||||
@@ -237,6 +240,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHappExcludeApns": false,
|
||||
"subHappExcludeRoutes": "",
|
||||
"subHappFallbackUrl": "",
|
||||
"subHappLocalProxyAuth": "",
|
||||
"subHappNewUrl": "",
|
||||
"subHappNoLimit": false,
|
||||
"subHappNotificationExpire": false,
|
||||
@@ -369,6 +373,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"reverse": null,
|
||||
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||
"security": "",
|
||||
@@ -408,6 +413,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
@@ -457,6 +463,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"reverse": null,
|
||||
"secret": "",
|
||||
"security": "",
|
||||
@@ -468,6 +475,25 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"updatedAt": 0,
|
||||
"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": {
|
||||
"tag": ""
|
||||
},
|
||||
@@ -487,6 +513,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
@@ -505,6 +532,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -591,6 +619,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -636,6 +665,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -700,6 +730,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -1017,6 +1048,37 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"key": "",
|
||||
"value": ""
|
||||
},
|
||||
"Sponsor": {
|
||||
"enable": true,
|
||||
"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,
|
||||
"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": {
|
||||
"createdAt": 1710000000000,
|
||||
"enabled": true,
|
||||
|
||||
@@ -43,6 +43,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -262,6 +265,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -493,6 +499,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -560,6 +567,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -674,6 +682,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -917,6 +928,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1148,6 +1162,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -1223,6 +1238,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -1497,13 +1513,17 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
||||
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"description": "Max auto-renew count, 0 = unlimited",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1566,6 +1586,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"security",
|
||||
"subId",
|
||||
"tgId",
|
||||
@@ -1717,6 +1738,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"resetMax": {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
@@ -1772,6 +1796,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"reverse",
|
||||
"secret",
|
||||
"security",
|
||||
@@ -1785,6 +1810,97 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"tag": {
|
||||
@@ -1855,6 +1971,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "abcd1234",
|
||||
"type": "string"
|
||||
@@ -1889,6 +2009,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"totalGB",
|
||||
"updatedAt"
|
||||
@@ -1944,7 +2065,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"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,
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1953,6 +2074,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "i7tvdpeffi0hvvf1",
|
||||
"type": "string"
|
||||
@@ -1985,6 +2111,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"total",
|
||||
"up",
|
||||
@@ -2275,6 +2402,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2408,6 +2538,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
@@ -2452,6 +2583,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2578,6 +2712,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"required": [
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
@@ -4101,6 +4236,84 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Sponsor": {
|
||||
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"example": true,
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"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": {
|
||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||
"properties": {
|
||||
|
||||
@@ -3,6 +3,7 @@ export type GeoKind = string;
|
||||
export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type addrFamily = number;
|
||||
export type staticEgressResolver = string;
|
||||
export type trafficLocalApplyAction = number;
|
||||
export type transportBits = number;
|
||||
@@ -20,6 +21,7 @@ export interface AllSetting {
|
||||
discordMemory: number;
|
||||
discordRunTime: string;
|
||||
expireDiff: number;
|
||||
externalSubUserAgent: string;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
happLinkEnable: boolean;
|
||||
@@ -87,6 +89,7 @@ export interface AllSetting {
|
||||
subHappExcludeApns: boolean;
|
||||
subHappExcludeRoutes: string;
|
||||
subHappFallbackUrl: string;
|
||||
subHappLocalProxyAuth: string;
|
||||
subHappNewUrl: string;
|
||||
subHappNoLimit: boolean;
|
||||
subHappNotificationExpire: boolean;
|
||||
@@ -170,6 +173,7 @@ export interface AllSettingView {
|
||||
discordMemory: number;
|
||||
discordRunTime: string;
|
||||
expireDiff: number;
|
||||
externalSubUserAgent: string;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
happLinkEnable: boolean;
|
||||
@@ -245,6 +249,7 @@ export interface AllSettingView {
|
||||
subHappExcludeApns: boolean;
|
||||
subHappExcludeRoutes: string;
|
||||
subHappFallbackUrl: string;
|
||||
subHappLocalProxyAuth: string;
|
||||
subHappNewUrl: string;
|
||||
subHappNoLimit: boolean;
|
||||
subHappNotificationExpire: boolean;
|
||||
@@ -364,6 +369,7 @@ export interface Client {
|
||||
reset: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
reverse?: ClientReverse | null;
|
||||
secret?: string;
|
||||
security: string;
|
||||
@@ -415,6 +421,7 @@ export interface ClientRecord {
|
||||
reset: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
reverse: unknown;
|
||||
secret: string;
|
||||
security: string;
|
||||
@@ -427,6 +434,27 @@ export interface ClientRecord {
|
||||
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 {
|
||||
tag: string;
|
||||
}
|
||||
@@ -444,6 +472,7 @@ export interface ClientSlim {
|
||||
reset: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
subId: string;
|
||||
totalGB: number;
|
||||
traffic?: ClientTraffic | null;
|
||||
@@ -463,6 +492,7 @@ export interface ClientTraffic {
|
||||
resetCount: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
subId: string;
|
||||
total: number;
|
||||
up: number;
|
||||
@@ -537,6 +567,7 @@ export interface Host {
|
||||
address: string;
|
||||
allowInsecure: boolean;
|
||||
alpn: string[];
|
||||
cipherSuites: string;
|
||||
createdAt: number;
|
||||
echConfigList: string;
|
||||
excludeFromSubTypes: string[];
|
||||
@@ -573,6 +604,7 @@ export interface Host {
|
||||
export interface HostGroup {
|
||||
allowInsecure: boolean;
|
||||
alpn: string[];
|
||||
cipherSuites: string;
|
||||
echConfigList: string;
|
||||
excludeFromSubTypes: string[];
|
||||
finalMask: string;
|
||||
@@ -937,6 +969,23 @@ export interface Setting {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Sponsor {
|
||||
enable?: boolean | 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 {
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -12,6 +12,9 @@ export type ProcessState = z.infer<typeof ProcessStateSchema>;
|
||||
export const ProtocolSchema = z.string();
|
||||
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 type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
@@ -34,6 +37,7 @@ export const AllSettingSchema = z.object({
|
||||
discordMemory: z.number().int().min(0).max(100),
|
||||
discordRunTime: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalSubUserAgent: z.string(),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
happLinkEnable: z.boolean(),
|
||||
@@ -101,6 +105,7 @@ export const AllSettingSchema = z.object({
|
||||
subHappExcludeApns: z.boolean(),
|
||||
subHappExcludeRoutes: z.string(),
|
||||
subHappFallbackUrl: z.string(),
|
||||
subHappLocalProxyAuth: z.string(),
|
||||
subHappNewUrl: z.string(),
|
||||
subHappNoLimit: z.boolean(),
|
||||
subHappNotificationExpire: z.boolean(),
|
||||
@@ -185,6 +190,7 @@ export const AllSettingViewSchema = z.object({
|
||||
discordMemory: z.number().int().min(0).max(100),
|
||||
discordRunTime: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalSubUserAgent: z.string(),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
happLinkEnable: z.boolean(),
|
||||
@@ -260,6 +266,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subHappExcludeApns: z.boolean(),
|
||||
subHappExcludeRoutes: z.string(),
|
||||
subHappFallbackUrl: z.string(),
|
||||
subHappLocalProxyAuth: z.string(),
|
||||
subHappNewUrl: z.string(),
|
||||
subHappNoLimit: z.boolean(),
|
||||
subHappNotificationExpire: z.boolean(),
|
||||
@@ -383,6 +390,7 @@ export const ClientSchema = z.object({
|
||||
reset: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
||||
secret: z.string().optional(),
|
||||
security: z.string(),
|
||||
@@ -437,6 +445,7 @@ export const ClientRecordSchema = z.object({
|
||||
reset: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
reverse: z.unknown(),
|
||||
secret: z.string(),
|
||||
security: z.string(),
|
||||
@@ -450,6 +459,29 @@ export const ClientRecordSchema = z.object({
|
||||
});
|
||||
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({
|
||||
tag: z.string(),
|
||||
});
|
||||
@@ -468,6 +500,7 @@ export const ClientSlimSchema = z.object({
|
||||
reset: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
subId: z.string(),
|
||||
totalGB: z.number().int(),
|
||||
traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
|
||||
@@ -488,6 +521,7 @@ export const ClientTrafficSchema = z.object({
|
||||
resetCount: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
subId: z.string(),
|
||||
total: z.number().int(),
|
||||
up: z.number().int(),
|
||||
@@ -573,6 +607,7 @@ export const HostSchema = z.object({
|
||||
address: z.string(),
|
||||
allowInsecure: z.boolean(),
|
||||
alpn: z.array(z.string()),
|
||||
cipherSuites: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
echConfigList: z.string(),
|
||||
excludeFromSubTypes: z.array(z.string()),
|
||||
@@ -610,6 +645,7 @@ export type Host = z.infer<typeof HostSchema>;
|
||||
export const HostGroupSchema = z.object({
|
||||
allowInsecure: z.boolean(),
|
||||
alpn: z.array(z.string()),
|
||||
cipherSuites: z.string(),
|
||||
echConfigList: z.string(),
|
||||
excludeFromSubTypes: z.array(z.string()),
|
||||
finalMask: z.string(),
|
||||
@@ -996,6 +1032,25 @@ export const SettingSchema = z.object({
|
||||
});
|
||||
export type Setting = z.infer<typeof SettingSchema>;
|
||||
|
||||
export const SponsorSchema = z.object({
|
||||
enable: z.boolean().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({
|
||||
createdAt: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -696,6 +696,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
tgId: Number(base.tgId) || 0,
|
||||
reset: Number(base.reset) || 0,
|
||||
resetDay: Number(base.resetDay) || 0,
|
||||
resetWeekday: Number(base.resetWeekday) || 0,
|
||||
resetMax: Number(base.resetMax) || 0,
|
||||
trafficReset: base.trafficReset || 'never',
|
||||
trafficResetDay: Number(base.trafficResetDay) || 1,
|
||||
|
||||
@@ -14,6 +14,7 @@ const TITLE_KEYS: Record<string, string> = {
|
||||
'/outbound': 'menu.outbounds',
|
||||
'/routing': 'menu.routing',
|
||||
'/api-docs': 'menu.apiDocs',
|
||||
'/sponsors': 'menu.sponsors',
|
||||
};
|
||||
|
||||
export function usePageTitle() {
|
||||
|
||||
@@ -247,6 +247,10 @@
|
||||
padding: 8px 8px 12px;
|
||||
}
|
||||
|
||||
.sider-sponsor {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sidebar-pin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DiscordOutlined,
|
||||
@@ -43,6 +44,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
@@ -68,6 +70,7 @@ type IconName =
|
||||
| 'cluster'
|
||||
| 'hosts'
|
||||
| 'logout'
|
||||
| 'sponsors'
|
||||
| 'apidocs'
|
||||
| 'outbound'
|
||||
| 'routing';
|
||||
@@ -82,6 +85,7 @@ const iconByName: Record<IconName, ComponentType> = {
|
||||
cluster: ClusterOutlined,
|
||||
hosts: GlobalOutlined,
|
||||
logout: LogoutOutlined,
|
||||
sponsors: CrownOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
outbound: ExportOutlined,
|
||||
routing: SwapOutlined,
|
||||
@@ -232,6 +236,7 @@ export default function AppSidebar() {
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
|
||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||
],
|
||||
[t],
|
||||
@@ -447,6 +452,13 @@ export default function AppSidebar() {
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<div className="sider-footer">
|
||||
<SponsorSlot
|
||||
slot="sidebar"
|
||||
variant="compact"
|
||||
iconOnly={railCollapsed}
|
||||
rotate
|
||||
className="sider-sponsor"
|
||||
/>
|
||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
@@ -532,6 +544,7 @@ export default function AppSidebar() {
|
||||
}}
|
||||
/>
|
||||
<div className="drawer-footer">
|
||||
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
|
||||
<VersionBadge version={panelVersion} />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export class AllSetting {
|
||||
restartXrayOnClientDisable = true;
|
||||
subCertFile = '';
|
||||
subKeyFile = '';
|
||||
externalSubUserAgent = 'v2rayNG/1.8.5';
|
||||
subUpdates = 12;
|
||||
subEncrypt = true;
|
||||
subURI = '';
|
||||
@@ -104,6 +105,7 @@ export class AllSetting {
|
||||
subHappAutoConnectType = 'lowestdelay';
|
||||
subHappPerAppMode = 'off';
|
||||
subHappPerAppList = '';
|
||||
subHappLocalProxyAuth = 'auto';
|
||||
|
||||
timeLocation = 'Local';
|
||||
|
||||
|
||||
@@ -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.',
|
||||
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); expired entries 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',
|
||||
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}',
|
||||
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',
|
||||
path: '/panel/api/clients/update/:email',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useClients, type InboundOption } from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
import ClientRenewalFields from './ClientRenewalFields';
|
||||
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
@@ -57,6 +58,7 @@ const EMPTY: ClientBulkAddFormValues = {
|
||||
expiryTime: 0,
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 0,
|
||||
resetMax: 0,
|
||||
trafficReset: 'never' as const,
|
||||
trafficResetDay: 1,
|
||||
@@ -215,6 +217,7 @@ export default function ClientBulkAddModal({
|
||||
expiryTime: current.expiryTime,
|
||||
reset: Number(current.reset) || 0,
|
||||
resetDay: Number(current.resetDay) || 0,
|
||||
resetWeekday: Number(current.resetWeekday) || 0,
|
||||
resetMax: Number(current.resetMax) || 0,
|
||||
trafficReset: current.trafficReset || 'never',
|
||||
trafficResetDay: Number(current.trafficResetDay) || 1,
|
||||
@@ -437,32 +440,13 @@ export default function ClientBulkAddModal({
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
name="reset"
|
||||
label={t('pages.clients.renew')}
|
||||
tooltip={t('pages.clients.renewDesc')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<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>
|
||||
<ClientRenewalFields
|
||||
active={open}
|
||||
delayedStart={delayedStart}
|
||||
expiryTime={expiryTime}
|
||||
bulk
|
||||
setExpiry={(expiry) => methods.setValue('expiryTime', expiry)}
|
||||
/>
|
||||
|
||||
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||
<Select
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
ExternalLinkInput,
|
||||
} from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
import ClientRenewalFields from './ClientRenewalFields';
|
||||
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
||||
import './ClientFormModal.css';
|
||||
|
||||
@@ -155,6 +156,7 @@ const EMPTY: Values = {
|
||||
delayedDays: 0,
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 0,
|
||||
resetMax: 0,
|
||||
trafficReset: 'never' as const,
|
||||
trafficResetDay: 1,
|
||||
@@ -259,6 +261,7 @@ export default function ClientFormModal({
|
||||
const methods = useForm<Values>({ defaultValues: EMPTY });
|
||||
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
||||
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 enable = useWatch({ control: methods.control, name: 'enable' });
|
||||
const flow = useWatch({ control: methods.control, name: 'flow' });
|
||||
@@ -366,6 +369,7 @@ export default function ClientFormModal({
|
||||
totalGB: bytesToGB(client.totalGB || 0),
|
||||
reset: Number(client.reset) || 0,
|
||||
resetDay: Number(client.resetDay) || 0,
|
||||
resetWeekday: Number(client.resetWeekday) || 0,
|
||||
resetMax: Number(client.resetMax) || 0,
|
||||
trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
|
||||
trafficResetDay: Number(client.trafficResetDay) || 1,
|
||||
@@ -663,6 +667,7 @@ export default function ClientFormModal({
|
||||
delayedDays: values.delayedDays,
|
||||
reset: values.reset,
|
||||
resetDay: values.resetDay,
|
||||
resetWeekday: values.resetWeekday,
|
||||
resetMax: values.resetMax,
|
||||
trafficReset: values.trafficReset,
|
||||
trafficResetDay: values.trafficResetDay,
|
||||
@@ -696,6 +701,7 @@ export default function ClientFormModal({
|
||||
expiryTime,
|
||||
reset: Number(values.reset) || 0,
|
||||
resetDay: Number(values.resetDay) || 0,
|
||||
resetWeekday: Number(values.resetWeekday) || 0,
|
||||
resetMax: Number(values.resetMax) || 0,
|
||||
trafficReset: values.trafficReset || 'never',
|
||||
trafficResetDay: Number(values.trafficResetDay) || 1,
|
||||
@@ -985,35 +991,16 @@ export default function ClientFormModal({
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<FormField
|
||||
name="reset"
|
||||
label={t('pages.clients.renewDays')}
|
||||
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 xs={24}>
|
||||
<ClientRenewalFields
|
||||
active={open}
|
||||
delayedStart={delayedStart}
|
||||
expiryTime={
|
||||
delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0
|
||||
}
|
||||
resetCount={client?.traffic?.resetCount || 0}
|
||||
setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<FormField
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -56,6 +57,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
|
||||
path: host?.path ?? '',
|
||||
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
|
||||
fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
|
||||
cipherSuites: host?.cipherSuites ?? '',
|
||||
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
|
||||
keepSniBlank: host?.keepSniBlank ?? false,
|
||||
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
|
||||
@@ -332,6 +334,12 @@ export default function HostFormModal({
|
||||
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="cipherSuites"
|
||||
label={t('pages.inbounds.form.cipherSuites')}
|
||||
>
|
||||
<CipherSuitesSelect />
|
||||
</FormField>
|
||||
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
</FormField>
|
||||
|
||||
@@ -103,13 +103,13 @@ export default function AmneziawgFields({
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<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 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 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 name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
|
||||
<InputNumber min={0} max={32} style={{ width: '100%' }} />
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import {
|
||||
ALPN_OPTION,
|
||||
DOMAIN_STRATEGY_OPTION,
|
||||
TLS_CIPHER_OPTION,
|
||||
TLS_VERSION_OPTION,
|
||||
USAGE_OPTION,
|
||||
UTLS_FINGERPRINT,
|
||||
@@ -240,12 +240,7 @@ export default function TlsForm({
|
||||
name={['streamSettings', 'tlsSettings', 'cipherSuites']}
|
||||
label={t('pages.inbounds.form.cipherSuites')}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: t('pages.inbounds.form.autoOption') },
|
||||
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
|
||||
]}
|
||||
/>
|
||||
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
|
||||
<Space.Compact block>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { LazyMount } from '@/components/utility';
|
||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import OverviewActionBar from './OverviewActionBar';
|
||||
import VitalTile from './VitalTile';
|
||||
@@ -213,6 +214,8 @@ export default function IndexPage() {
|
||||
onOpenVersionSwitch={() => setVersionOpen(true)}
|
||||
/>
|
||||
|
||||
<SponsorSlot slot="dashboard" />
|
||||
|
||||
{health && (
|
||||
<div className="ov-health" style={{ color: health.color }}>
|
||||
<span className="ov-health-mark" />
|
||||
|
||||
@@ -411,3 +411,7 @@
|
||||
.submit-row {
|
||||
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 { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
|
||||
import './LoginPage.css';
|
||||
@@ -247,6 +248,7 @@ export default function LoginPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
<SponsorSlot slot="login" variant="compact" className="login-sponsor" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ interface ApiMsg<T = unknown> {
|
||||
|
||||
const REFRESH_MS = 15000;
|
||||
|
||||
const formatKbps = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
||||
|
||||
export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
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
|
||||
// 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') => {
|
||||
try {
|
||||
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}
|
||||
markerRadius={2.6}
|
||||
showTooltip
|
||||
valueMax={null}
|
||||
yFormatter={formatKbps}
|
||||
/>
|
||||
</div>
|
||||
<div className="series">
|
||||
@@ -164,6 +168,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
||||
fillOpacity={0.18}
|
||||
markerRadius={2.6}
|
||||
showTooltip
|
||||
valueMax={null}
|
||||
yFormatter={formatKbps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
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 {
|
||||
BranchesOutlined,
|
||||
BuildOutlined,
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets';
|
||||
import { buildHappPresetDeeplink } from './happPresets';
|
||||
import HappRoutingEditorModal from './HappRoutingEditorModal';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
|
||||
interface HappSettingsContentProps {
|
||||
@@ -32,38 +33,20 @@ export default function HappSettingsContent({
|
||||
defaultActiveTab = 'routing',
|
||||
}: HappSettingsContentProps) {
|
||||
const { t } = useTranslation();
|
||||
// Generator choices stay local until Apply updates the draft; page Save persists it.
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
|
||||
const [includeAdblock, setIncludeAdblock] = 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 payload = buildHappPresetDeeplink(selectedPreset);
|
||||
const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
|
||||
if (payload) {
|
||||
updateSetting({ subRoutingRules: payload });
|
||||
message.success(t('pages.settings.subHappPresetApplied'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuildDeeplink = () => {
|
||||
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));
|
||||
const handleBuildDeeplink = (deeplink: string) => {
|
||||
updateSetting({ subRoutingRules: deeplink });
|
||||
setIsModalOpen(false);
|
||||
message.success(t('pages.settings.subHappDeeplinkGenerated'));
|
||||
@@ -112,21 +95,31 @@ export default function HappSettingsContent({
|
||||
title={t('pages.settings.subHappPresets')}
|
||||
description={t('pages.settings.subHappPresetsDesc')}
|
||||
>
|
||||
<Space orientation="horizontal" style={{ width: '100%' }}>
|
||||
<Space orientation="horizontal" wrap style={{ width: '100%' }}>
|
||||
<Select
|
||||
aria-label={t('pages.settings.subHappPresets')}
|
||||
value={selectedPreset}
|
||||
style={{ minWidth: 170 }}
|
||||
onChange={setSelectedPreset}
|
||||
options={[
|
||||
{ value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
|
||||
{ value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
|
||||
{ value: 'adblock', label: t('pages.settings.subHappPresetAdblock') },
|
||||
{ value: 'global', label: t('pages.settings.subHappPresetGlobal') },
|
||||
{ value: 'lan-bypass', label: t('pages.settings.subHappPresetLocal') },
|
||||
{ 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}>
|
||||
{t('pages.settings.subHappPresets')}
|
||||
{t('pages.settings.subHappApplyPreset')}
|
||||
</Button>
|
||||
</Space>
|
||||
</SettingListItem>
|
||||
@@ -382,6 +375,23 @@ export default function HappSettingsContent({
|
||||
/>
|
||||
</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
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappAutoConnect')}
|
||||
@@ -572,83 +582,14 @@ export default function HappSettingsContent({
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('pages.settings.subHappModalTitle')}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onOk={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>
|
||||
<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>
|
||||
{/* Mount per opening so canceled edits are discarded and the latest parent draft is loaded. */}
|
||||
{isModalOpen ? (
|
||||
<HappRoutingEditorModal
|
||||
input={allSetting.subRoutingRules}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onGenerate={handleBuildDeeplink}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,6 +247,26 @@ export default function SubscriptionGeneralTab({
|
||||
onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.externalSubUserAgent')}
|
||||
badge={
|
||||
<DefaultSettingTag
|
||||
settingKey="externalSubUserAgent"
|
||||
value={allSetting.externalSubUserAgent}
|
||||
/>
|
||||
}
|
||||
description={t('pages.settings.externalSubUserAgentDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.externalSubUserAgent}
|
||||
placeholder="v2rayNG/1.8.5"
|
||||
maxLength={512}
|
||||
allowClear
|
||||
onChange={(e) => updateSetting({ externalSubUserAgent: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,16 +7,11 @@ export function toBase64Utf8(str: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Splits multiline or comma-separated string into clean unique token arrays.
|
||||
export function parseList(input: string): string[] {
|
||||
return input
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Build standard Happ routing deeplink or special state for curated presets.
|
||||
export function buildHappPresetDeeplink(preset: string): string {
|
||||
export function buildHappPresetDeeplink(preset: string, includeAdblock = false): string {
|
||||
// Ad blocking is opt-in for each generated profile, independent of the base routing rules.
|
||||
const blockSites = includeAdblock ? ['geosite:category-ads-all'] : [];
|
||||
|
||||
switch (preset) {
|
||||
case 'off':
|
||||
return 'happ://routing/off';
|
||||
@@ -27,9 +22,20 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
JSON.stringify({
|
||||
Name: 'Iran Bypass',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['domain:ir', 'regexp:.*\\.ir$'],
|
||||
DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
DirectSites: ['geosite:private', 'domain:ir', 'geosite:category-ir'],
|
||||
DirectIp: [
|
||||
'geoip:ir',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
@@ -42,32 +48,38 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'China Direct',
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
BlockIp: [],
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
}),
|
||||
)
|
||||
);
|
||||
case 'adblock':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'AdBlock',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
}),
|
||||
)
|
||||
);
|
||||
@@ -80,7 +92,25 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
BlockSites: [],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'AsIs',
|
||||
}),
|
||||
)
|
||||
);
|
||||
// LAN bypass has its own identity so applying it does not overwrite the Global profile.
|
||||
case 'lan-bypass':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'Global Bypass Local Network',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:private'],
|
||||
DirectIp: ['geoip:private'],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { HappRoutingProfileSchema, type HappRoutingProfile } from '@/schemas/happRouting';
|
||||
import { toBase64Utf8 } from './happPresets';
|
||||
import { isRemoteRoutingSource } from './subscriptionShared';
|
||||
|
||||
export type HappRoutingMode = 'add' | 'onadd';
|
||||
export type HappRoutingListKey =
|
||||
| 'DirectSites'
|
||||
| 'DirectIp'
|
||||
| 'ProxySites'
|
||||
| 'ProxyIp'
|
||||
| 'BlockSites'
|
||||
| 'BlockIp';
|
||||
|
||||
export type HappRoutingLoadResult =
|
||||
| { success: true; profile: HappRoutingProfile; mode: HappRoutingMode; isNew: boolean }
|
||||
| { success: false; error: 'off' | 'remote' | 'invalid' };
|
||||
|
||||
export function parseHappRoutingJson(input: string): HappRoutingProfile | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(input);
|
||||
const result = HappRoutingProfileSchema.safeParse(value);
|
||||
// Keep the validated input object; schema output can discard some unknown extension keys.
|
||||
return result.success ? (value as HappRoutingProfile) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadHappRouting(input: string): HappRoutingLoadResult {
|
||||
const source = input.trim();
|
||||
if (!source) {
|
||||
return {
|
||||
success: true,
|
||||
profile: {
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
},
|
||||
mode: 'onadd',
|
||||
isNew: true,
|
||||
};
|
||||
}
|
||||
if (source === 'happ://routing/off') return { success: false, error: 'off' };
|
||||
if (isRemoteRoutingSource(source)) return { success: false, error: 'remote' };
|
||||
|
||||
if (source.startsWith('{')) {
|
||||
const profile = parseHappRoutingJson(source);
|
||||
return profile
|
||||
? { success: true, profile, mode: 'onadd', isNew: false }
|
||||
: { success: false, error: 'invalid' };
|
||||
}
|
||||
|
||||
const match = /^happ:\/\/routing\/(onadd|add)\/([A-Za-z0-9+/_-]+={0,2})$/.exec(source);
|
||||
if (!match) return { success: false, error: 'invalid' };
|
||||
|
||||
try {
|
||||
const binary = atob(match[2].replace(/-/g, '+').replace(/_/g, '/'));
|
||||
// Decode UTF-8 strictly so malformed bytes cannot silently change profile names or rules.
|
||||
const json = new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
Uint8Array.from(binary, (character) => character.charCodeAt(0)),
|
||||
);
|
||||
const profile = parseHappRoutingJson(json);
|
||||
return profile
|
||||
? { success: true, profile, mode: match[1] as HappRoutingMode, isNew: false }
|
||||
: { success: false, error: 'invalid' };
|
||||
} catch {
|
||||
return { success: false, error: 'invalid' };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHappRoutingDeeplink(
|
||||
profile: HappRoutingProfile,
|
||||
mode: HappRoutingMode = 'onadd',
|
||||
): string {
|
||||
return `happ://routing/${mode}/` + toBase64Utf8(JSON.stringify(profile));
|
||||
}
|
||||
|
||||
export function parseHappRoutingList(input: string): string[] {
|
||||
// Commas can belong to regexp rules, so the editor uses one rule per line.
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.sponsors-page .ant-layout,
|
||||
.sponsors-page .ant-layout-content,
|
||||
.sponsors-page .content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sponsors-page .content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sponsors-inner {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.sponsors-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sponsors-page .sponsors-title {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.sponsors-title-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.sponsors-page .sponsors-section-title {
|
||||
margin: 32px 0 12px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sponsors-yourbrand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
min-height: 170px;
|
||||
padding: 16px;
|
||||
border: 1.5px dashed var(--ant-color-border);
|
||||
border-radius: var(--ant-border-radius-lg, 8px);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sponsors-yourbrand.is-large {
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
background: var(--bg-card, transparent);
|
||||
}
|
||||
|
||||
.sponsors-yourbrand-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
font-size: 22px;
|
||||
color: var(--ant-color-primary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.sponsors-yourbrand-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.sponsors-yourbrand-text {
|
||||
max-width: 420px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.sponsors-yourbrand:not(.is-large) .ant-btn {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sponsors-placement {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: var(--ant-border-radius-lg, 8px);
|
||||
background: var(--bg-card, transparent);
|
||||
}
|
||||
|
||||
.sponsors-placement-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 17px;
|
||||
color: var(--ant-color-primary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.sponsors-placement-title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.sponsors-placement-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sponsors-placement-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sponsors-placement-status .ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sponsors-placement-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sponsors-page .content-area {
|
||||
padding: 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
|
||||
import {
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
LoginOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||
import SponsorCard from '@/components/sponsor/SponsorCard';
|
||||
import { IntlUtil } from '@/utils';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
|
||||
import './SponsorsPage.css';
|
||||
|
||||
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CrownOutlined />}
|
||||
href={contact}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
block={block}
|
||||
>
|
||||
{t('pages.sponsors.become')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
|
||||
<span className="sponsors-yourbrand-icon">
|
||||
<PlusOutlined />
|
||||
</span>
|
||||
<div className="sponsors-yourbrand-title">{t('pages.sponsors.yourBrand')}</div>
|
||||
<div className="sponsors-yourbrand-text">{t('pages.sponsors.yourBrandText')}</div>
|
||||
<BecomeButton contact={contact} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SponsorsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { data, fetched } = useSponsorsQuery();
|
||||
const sponsors = sponsorsForSlot(data.sponsors, 'page');
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
|
||||
{
|
||||
slot: 'dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
title: t('pages.sponsors.placementDashboard'),
|
||||
desc: t('pages.sponsors.placementDashboardDesc'),
|
||||
},
|
||||
{
|
||||
slot: 'sidebar',
|
||||
icon: <MenuUnfoldOutlined />,
|
||||
title: t('pages.sponsors.placementSidebar'),
|
||||
desc: t('pages.sponsors.placementSidebarDesc'),
|
||||
},
|
||||
{
|
||||
slot: 'login',
|
||||
icon: <LoginOutlined />,
|
||||
title: t('pages.sponsors.placementLogin'),
|
||||
desc: t('pages.sponsors.placementLoginDesc'),
|
||||
},
|
||||
{
|
||||
slot: 'page',
|
||||
icon: <CrownOutlined />,
|
||||
title: t('pages.sponsors.placementPage'),
|
||||
desc: t('pages.sponsors.placementPageDesc'),
|
||||
},
|
||||
];
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['sponsors-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<div className="sponsors-inner">
|
||||
<div className="sponsors-header">
|
||||
<div>
|
||||
<Typography.Title level={3} className="sponsors-title">
|
||||
<CrownOutlined className="sponsors-title-icon" /> {t('pages.sponsors.title')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">{t('pages.sponsors.intro')}</Typography.Text>
|
||||
</div>
|
||||
<BecomeButton contact={data.contact} />
|
||||
</div>
|
||||
|
||||
<Spin spinning={!fetched} delay={200}>
|
||||
{sponsors.length === 0 ? (
|
||||
fetched && <YourBrandCard contact={data.contact} large />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{sponsors.map((sponsor) => (
|
||||
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
|
||||
<SponsorCard sponsor={sponsor} variant="card" />
|
||||
</Col>
|
||||
))}
|
||||
{data.contact && (
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
<YourBrandCard contact={data.contact} />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Typography.Title level={5} className="sponsors-section-title">
|
||||
{t('pages.sponsors.placements')}
|
||||
</Typography.Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{placements.map((p) => {
|
||||
const status = placementStatus(data.sponsors, p.slot);
|
||||
return (
|
||||
<Col key={p.slot} xs={24} sm={12} xl={6}>
|
||||
<div className="sponsors-placement">
|
||||
<span className="sponsors-placement-icon">{p.icon}</span>
|
||||
<div className="sponsors-placement-body">
|
||||
<div className="sponsors-placement-title">{p.title}</div>
|
||||
<div className="sponsors-placement-desc">{p.desc}</div>
|
||||
<div className="sponsors-placement-status">
|
||||
{status.takenUntil ? (
|
||||
<Tag color="orange">
|
||||
{t('pages.sponsors.takenUntil', {
|
||||
date: IntlUtil.formatDate(status.takenUntil, datepicker),
|
||||
})}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="green">{t('pages.sponsors.available')}</Tag>
|
||||
)}
|
||||
{status.count > 0 && !status.takenUntil && (
|
||||
<Tag>
|
||||
{t('pages.sponsors.activeCount', {
|
||||
count:
|
||||
status.capacity && status.capacity > 1
|
||||
? `${status.count}/${status.capacity}`
|
||||
: status.count,
|
||||
})}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -70,15 +70,19 @@ export default function AmneziawgFields() {
|
||||
<ObfNumber name="jc" label={t('pages.xray.amneziawg.jc')} min={0} />
|
||||
<ObfNumber name="jmin" label={t('pages.xray.amneziawg.jmin')} min={0} />
|
||||
<ObfNumber name="jmax" label={t('pages.xray.amneziawg.jmax')} min={0} />
|
||||
<ObfNumber name="s1" label={t('pages.xray.amneziawg.s1')} min={0} />
|
||||
<ObfNumber name="s2" label={t('pages.xray.amneziawg.s2')} min={0} />
|
||||
<ObfNumber name="s3" label={t('pages.xray.amneziawg.s3')} min={0} max={64} />
|
||||
<ObfNumber name="s1" label={t('pages.xray.amneziawg.s1')} min={0} max={65535} />
|
||||
<ObfNumber name="s2" label={t('pages.xray.amneziawg.s2')} min={0} max={65535} />
|
||||
<ObfNumber name="s3" label={t('pages.xray.amneziawg.s3')} min={0} max={65535} />
|
||||
<ObfNumber name="s4" label={t('pages.xray.amneziawg.s4')} min={0} max={32} />
|
||||
<ObfText name="h1" label={t('pages.xray.amneziawg.h1')} placeholder="100-800" />
|
||||
<ObfText name="h2" label={t('pages.xray.amneziawg.h2')} placeholder="900-1600" />
|
||||
<ObfText name="h3" label={t('pages.xray.amneziawg.h3')} placeholder="1700-2400" />
|
||||
<ObfText name="h4" label={t('pages.xray.amneziawg.h4')} placeholder="2500-3200" />
|
||||
<ObfText name="i1" label={t('pages.xray.amneziawg.i1')} placeholder="<r 64>" />
|
||||
<ObfText name="i2" label={t('pages.xray.amneziawg.i2')} placeholder="<r 64>" />
|
||||
<ObfText name="i3" label={t('pages.xray.amneziawg.i3')} placeholder="<r 64>" />
|
||||
<ObfText name="i4" label={t('pages.xray.amneziawg.i4')} placeholder="<r 64>" />
|
||||
<ObfText name="i5" label={t('pages.xray.amneziawg.i5')} placeholder="<r 64>" />
|
||||
<ObfText
|
||||
name="contentPaddingAddition"
|
||||
label={t('pages.xray.amneziawg.contentPaddingAddition')}
|
||||
|
||||
@@ -86,6 +86,13 @@ const EMPTY: NordFormValues = {
|
||||
serverId: null,
|
||||
};
|
||||
|
||||
// antd warns on a null option value, so "All Cities" is a sentinel mapped back to null.
|
||||
const ALL_CITIES = '__all__';
|
||||
const allCitiesTransform = {
|
||||
input: (value: unknown) => value ?? ALL_CITIES,
|
||||
output: (value: unknown) => (value === ALL_CITIES ? null : value),
|
||||
};
|
||||
|
||||
function loadLevel(load: number): 'low' | 'medium' | 'high' {
|
||||
if (load < 30) return 'low';
|
||||
if (load < 70) return 'medium';
|
||||
@@ -452,12 +459,16 @@ export default function NordModal({
|
||||
</FormField>
|
||||
|
||||
{cities.length > 0 && (
|
||||
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
||||
<FormField
|
||||
name="cityId"
|
||||
label={t('pages.xray.outbound.city')}
|
||||
transform={allCitiesTransform}
|
||||
>
|
||||
<Select
|
||||
data-testid="nord-city-select"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={[
|
||||
{ value: null, label: t('pages.xray.outbound.allCities') },
|
||||
{ value: ALL_CITIES, label: t('pages.xray.outbound.allCities') },
|
||||
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -73,6 +73,13 @@ const EMPTY: PiaFormValues = {
|
||||
hostname: null,
|
||||
};
|
||||
|
||||
// antd warns on a null option value, so "All Regions" is a sentinel mapped back to null.
|
||||
const ALL_REGIONS = '__all__';
|
||||
const allRegionsTransform = {
|
||||
input: (value: unknown) => value ?? ALL_REGIONS,
|
||||
output: (value: unknown) => (value === ALL_REGIONS ? null : value),
|
||||
};
|
||||
|
||||
function piaHostnameOf(outbound: PiaOutboundRow): string {
|
||||
if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
|
||||
return outbound.piaHostname.trim();
|
||||
@@ -389,12 +396,16 @@ export default function PiaModal({
|
||||
</FormField>
|
||||
|
||||
{regions.length > 0 && (
|
||||
<FormField name="regionId" label={t('pages.xray.pia.region')}>
|
||||
<FormField
|
||||
name="regionId"
|
||||
label={t('pages.xray.pia.region')}
|
||||
transform={allRegionsTransform}
|
||||
>
|
||||
<Select
|
||||
data-testid="pia-region-select"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={[
|
||||
{ value: null, label: t('pages.xray.pia.allRegions') },
|
||||
{ value: ALL_REGIONS, label: t('pages.xray.pia.allRegions') },
|
||||
...regions.map((r) => ({ value: r.id, label: r.name })),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
||||
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
||||
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
||||
const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
|
||||
|
||||
function withSuspense(node: React.ReactNode) {
|
||||
return (
|
||||
@@ -51,6 +52,7 @@ const routes: RouteObject[] = [
|
||||
{ path: 'outbound', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'routing', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
|
||||
{ path: 'sponsors', element: withSuspense(<SponsorsPage />) },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -35,6 +35,7 @@ export const HostFormSchema = z.object({
|
||||
(val) => (val === '' ? undefined : val),
|
||||
UtlsFingerprintSchema.optional(),
|
||||
),
|
||||
cipherSuites: z.string().default(''),
|
||||
overrideSniFromAddress: z.boolean().default(false),
|
||||
keepSniBlank: z.boolean().default(false),
|
||||
pinnedPeerCertSha256: z.array(z.string()).default([]),
|
||||
@@ -87,6 +88,7 @@ export const HostRecordSchema = z
|
||||
path: z.string().optional(),
|
||||
alpn: z.array(z.string()).nullish(),
|
||||
fingerprint: z.string().optional(),
|
||||
cipherSuites: z.string().optional(),
|
||||
overrideSniFromAddress: z.boolean().optional(),
|
||||
keepSniBlank: z.boolean().optional(),
|
||||
pinnedPeerCertSha256: z.array(z.string()).nullish(),
|
||||
|
||||
@@ -41,6 +41,7 @@ export const ClientRecordSchema = z
|
||||
enable: z.boolean().optional(),
|
||||
reset: z.number().optional(),
|
||||
resetDay: z.number().optional(),
|
||||
resetWeekday: z.number().optional(),
|
||||
resetMax: z.number().optional(),
|
||||
trafficReset: z.string().optional(),
|
||||
trafficResetDay: z.number().optional(),
|
||||
@@ -328,6 +329,7 @@ export const ClientFormSchema = z.object({
|
||||
delayedDays: z.number().int().min(0),
|
||||
reset: z.number().int().min(0),
|
||||
resetDay: z.number().int().min(0).max(31),
|
||||
resetWeekday: z.number().int().min(0).max(7),
|
||||
resetMax: z.number().int().min(0),
|
||||
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
||||
trafficResetDay: z.number().int().min(1).max(31),
|
||||
@@ -360,7 +362,7 @@ export const ClientBulkAdjustFormSchema = z
|
||||
(v.limitHwid !== undefined && v.limitHwid !== null) ||
|
||||
(v.adTag !== undefined && v.adTag.trim() !== ''),
|
||||
{
|
||||
message: 'pages.clients.bulkAdjustNothing',
|
||||
error: 'pages.clients.bulkAdjustNothing',
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
@@ -370,7 +372,7 @@ export const ClientBulkAdjustFormSchema = z
|
||||
return /^[0-9a-fA-F]{32}$/.test(tag);
|
||||
},
|
||||
{
|
||||
message: 'pages.inbounds.form.mtgAdTagInvalid',
|
||||
error: 'pages.inbounds.form.mtgAdTagInvalid',
|
||||
path: ['adTag'],
|
||||
},
|
||||
);
|
||||
@@ -392,6 +394,7 @@ export const ClientBulkAddFormSchema = z.object({
|
||||
expiryTime: z.number(),
|
||||
reset: z.number().int().min(0),
|
||||
resetDay: z.number().int().min(0).max(31),
|
||||
resetWeekday: z.number().int().min(0).max(7),
|
||||
resetMax: z.number().int().min(0),
|
||||
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']).optional(),
|
||||
trafficResetDay: z.number().int().min(1).max(31).optional(),
|
||||
|
||||
@@ -52,7 +52,7 @@ const InboundTlsSettingsSchema = TlsStreamSettingsSchema.extend({
|
||||
.array(InboundTlsCertSchema)
|
||||
.default([])
|
||||
.refine((certificates) => certificates.some((cert) => cert.usage !== 'verify'), {
|
||||
message: 'pages.inbounds.form.tlsServerCertificateRequired',
|
||||
error: 'pages.inbounds.form.tlsServerCertificateRequired',
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Extensions belong to the Happ client and must survive a trip through this editor.
|
||||
export const HappRoutingProfileSchema = z
|
||||
.object({
|
||||
// The backend treats null lists as absent; preserve them until explicitly edited.
|
||||
DirectSites: z.array(z.string()).nullish(),
|
||||
DirectIp: z.array(z.string()).nullish(),
|
||||
ProxySites: z.array(z.string()).nullish(),
|
||||
ProxyIp: z.array(z.string()).nullish(),
|
||||
BlockSites: z.array(z.string()).nullish(),
|
||||
BlockIp: z.array(z.string()).nullish(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export type HappRoutingProfile = z.infer<typeof HappRoutingProfileSchema>;
|
||||
@@ -71,9 +71,9 @@ export const AmneziawgServerSchema = z.object({
|
||||
jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)),
|
||||
jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)),
|
||||
jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)),
|
||||
s1: clearedToDefault(z.number().int().min(0).max(65535).default(30)),
|
||||
s2: clearedToDefault(z.number().int().min(0).max(65535).default(45)),
|
||||
s3: clearedToDefault(z.number().int().min(0).max(64).default(10)),
|
||||
s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)),
|
||||
s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)),
|
||||
s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)),
|
||||
s4: clearedToDefault(z.number().int().min(0).max(32).default(5)),
|
||||
h1: z.string().default(''),
|
||||
h2: z.string().default(''),
|
||||
|
||||
@@ -22,9 +22,10 @@ export const AmneziaWGOutboundSettingsSchema = z.object({
|
||||
jc: z.number().int().min(0).default(0),
|
||||
jmin: z.number().int().min(0).default(40),
|
||||
jmax: z.number().int().min(0).default(100),
|
||||
s1: z.number().int().min(0).default(15),
|
||||
s2: z.number().int().min(0).default(80),
|
||||
s3: z.number().int().min(0).max(64).default(12),
|
||||
// The remote server sets S1-S3; only amneziawg-go's uint16 UAPI width bounds them here.
|
||||
s1: z.number().int().min(0).max(65535).default(15),
|
||||
s2: z.number().int().min(0).max(65535).default(80),
|
||||
s3: z.number().int().min(0).max(65535).default(12),
|
||||
s4: z.number().int().min(0).max(32).default(12),
|
||||
h1: z.string().default(''),
|
||||
h2: z.string().default(''),
|
||||
|
||||
@@ -72,6 +72,7 @@ export const AllSettingSchema = z
|
||||
restartXrayOnClientDisable: z.boolean().optional(),
|
||||
subCertFile: z.string().optional(),
|
||||
subKeyFile: z.string().optional(),
|
||||
externalSubUserAgent: z.string().max(512).optional(),
|
||||
subUpdates: z.number().int().min(0).max(525600).optional(),
|
||||
subEncrypt: z.boolean().optional(),
|
||||
subURI: z.string().optional(),
|
||||
@@ -109,6 +110,7 @@ export const AllSettingSchema = z
|
||||
subHappAutoConnectType: z.string().optional(),
|
||||
subHappPerAppMode: z.string().optional(),
|
||||
subHappPerAppList: z.string().optional(),
|
||||
subHappLocalProxyAuth: z.string().optional(),
|
||||
timeLocation: z.string().optional(),
|
||||
ldapEnable: z.boolean().optional(),
|
||||
ldapHost: z.string().optional(),
|
||||
|
||||
@@ -33,12 +33,12 @@ export const SubBalancerFormSchema = z.object({
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.number({ message: 'pages.settings.subBalancers.errWeightPositive' })
|
||||
.number({ error: 'pages.settings.subBalancers.errWeightPositive' })
|
||||
.positive('pages.settings.subBalancers.errWeightPositive'),
|
||||
)
|
||||
.optional(),
|
||||
sortOrder: z
|
||||
.number({ message: 'pages.settings.subBalancers.errSortOrder' })
|
||||
.number({ error: 'pages.settings.subBalancers.errSortOrder' })
|
||||
.int('pages.settings.subBalancers.errSortOrder')
|
||||
.min(1, 'pages.settings.subBalancers.errSortOrder'),
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -132,7 +132,7 @@ export const BalancerFormSchema = z.object({
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'pages.xray.balancerTagRequired')
|
||||
.refine((val) => !val.startsWith('_bl_'), { message: 'pages.xray.balancer.reservedPrefix' }),
|
||||
.refine((val) => !val.startsWith('_bl_'), { error: 'pages.xray.balancer.reservedPrefix' }),
|
||||
strategy: BalancerStrategyTypeSchema.default('random'),
|
||||
selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
|
||||
fallbackTag: z.string().default(''),
|
||||
@@ -143,7 +143,7 @@ export const OutboundTagSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'pages.xray.outboundTagRequired')
|
||||
.refine((val) => !val.startsWith('_bl_'), { message: 'pages.xray.balancer.reservedPrefix' });
|
||||
.refine((val) => !val.startsWith('_bl_'), { error: 'pages.xray.balancer.reservedPrefix' });
|
||||
|
||||
export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
|
||||
export type RuleFormValues = z.infer<typeof RuleFormSchema>;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
.settings-page,
|
||||
.nodes-page,
|
||||
.groups-page,
|
||||
.api-docs-page {
|
||||
.api-docs-page,
|
||||
.sponsors-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
@@ -19,7 +20,8 @@
|
||||
.settings-page.is-dark,
|
||||
.nodes-page.is-dark,
|
||||
.groups-page.is-dark,
|
||||
.api-docs-page.is-dark {
|
||||
.api-docs-page.is-dark,
|
||||
.sponsors-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
@@ -31,7 +33,8 @@
|
||||
.settings-page.is-dark.is-ultra,
|
||||
.nodes-page.is-dark.is-ultra,
|
||||
.groups-page.is-dark.is-ultra,
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
.api-docs-page.is-dark.is-ultra,
|
||||
.sponsors-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg';
|
||||
import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound/amneziawg';
|
||||
|
||||
// AntD InputNumber emits null when cleared; a cleared numeric field must
|
||||
// refill its schema default instead of failing validation and blocking the save.
|
||||
@@ -33,27 +34,27 @@ describe('AmneziawgServerSchema cleared numeric fields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The form must reject what amneziawg-go's UAPI parsers reject (device/uapi.go:
|
||||
// jc/jmin/jmax uint32, s1-s4 uint16), or the save silently outlives the apply.
|
||||
// The form must reject what cannot apply or be received: jc/jmin/jmax past uint32 (device/uapi.go),
|
||||
// S1-S3 past amneziawg-go's 1700-byte iOS receive buffer, S4 past 32 (MTU headroom).
|
||||
describe('AmneziawgServerSchema obfuscation bounds', () => {
|
||||
const overWidth: Array<[string, number]> = [
|
||||
['s1', 65536],
|
||||
['s2', 70000],
|
||||
['s3', 65],
|
||||
['s1', 1553],
|
||||
['s2', 1609],
|
||||
['s3', 1637],
|
||||
['s4', 33],
|
||||
['jc', 4294967296],
|
||||
['jmin', 4294967296],
|
||||
['jmax', 5000000000],
|
||||
];
|
||||
|
||||
it.each(overWidth)('rejects %s above the width amneziawg-go parses', (field, value) => {
|
||||
it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => {
|
||||
expect(AmneziawgServerSchema.safeParse({ [field]: value }).success).toBe(false);
|
||||
});
|
||||
|
||||
const atLimit: Array<[string, number]> = [
|
||||
['s1', 65535],
|
||||
['s2', 65535],
|
||||
['s3', 64],
|
||||
['s1', 1552],
|
||||
['s2', 1608],
|
||||
['s3', 1636],
|
||||
['s4', 32],
|
||||
['jc', 4294967295],
|
||||
];
|
||||
@@ -69,3 +70,15 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// An outbound's S values come from the remote server and are received on Linux,
|
||||
// so only amneziawg-go's uint16 UAPI width bounds them, not the iOS buffer.
|
||||
describe('AmneziaWGOutboundSettingsSchema padding bounds', () => {
|
||||
it.each(['s1', 's2', 's3'])('accepts %s past the inbound iOS cap', (field) => {
|
||||
expect(AmneziaWGOutboundSettingsSchema.safeParse({ [field]: 2000 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['s1', 's2', 's3'])('rejects %s past uint16', (field) => {
|
||||
expect(AmneziaWGOutboundSettingsSchema.safeParse({ [field]: 65536 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -13,16 +13,19 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function renderSidebar() {
|
||||
return renderWithProviders(
|
||||
// rc-menu registers its items in a microtask after render; settle it inside act().
|
||||
async function renderSidebar() {
|
||||
const view = renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await act(async () => {});
|
||||
return view;
|
||||
}
|
||||
|
||||
test('keeps the sidebar expanded after pinning it from the header and restores the choice', () => {
|
||||
const first = renderSidebar();
|
||||
test('keeps the sidebar expanded after pinning it from the header and restores the choice', async () => {
|
||||
const first = await renderSidebar();
|
||||
const sidebar = first.container.querySelector('.ant-layout-sider');
|
||||
const sidebarRoot = first.container.querySelector('.ant-sidebar');
|
||||
|
||||
@@ -42,7 +45,7 @@ test('keeps the sidebar expanded after pinning it from the header and restores t
|
||||
|
||||
first.unmount();
|
||||
|
||||
const second = renderSidebar();
|
||||
const second = await renderSidebar();
|
||||
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
|
||||
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
|
||||
|
||||
@@ -51,8 +54,8 @@ test('keeps the sidebar expanded after pinning it from the header and restores t
|
||||
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('returns to the compact rail after unpinning', () => {
|
||||
const view = renderSidebar();
|
||||
test('returns to the compact rail after unpinning', async () => {
|
||||
const view = await renderSidebar();
|
||||
const sidebar = view.container.querySelector('.ant-layout-sider');
|
||||
const sidebarRoot = view.container.querySelector('.ant-sidebar');
|
||||
|
||||
@@ -66,8 +69,8 @@ test('returns to the compact rail after unpinning', () => {
|
||||
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
|
||||
});
|
||||
|
||||
test('labels the palette shortcut with the modifier the platform actually uses', () => {
|
||||
const view = renderSidebar();
|
||||
test('labels the palette shortcut with the modifier the platform actually uses', async () => {
|
||||
const view = await renderSidebar();
|
||||
const chip = view.container.querySelector('.sidebar-command-kbd');
|
||||
expect(chip?.textContent).toBe('CtrlK');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
|
||||
function renderSelect(value: string) {
|
||||
const onChange = vi.fn();
|
||||
render(<CipherSuitesSelect aria-label="cipher suites" value={value} onChange={onChange} />);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('CipherSuitesSelect', () => {
|
||||
it('shows each colon-separated suite as its own tag', () => {
|
||||
renderSelect('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE');
|
||||
expect(screen.getByText('TLS_AES_256_GCM_SHA384')).toBeTruthy();
|
||||
expect(screen.getByText('MY_CUSTOM_SUITE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('stores a typed custom suite joined with colons after the existing one', () => {
|
||||
const onChange = renderSelect('TLS_AES_256_GCM_SHA384');
|
||||
const input = screen.getByRole('combobox', { name: 'cipher suites' });
|
||||
fireEvent.change(input, { target: { value: 'MY_CUSTOM_SUITE' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 });
|
||||
expect(onChange).toHaveBeenLastCalledWith('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE');
|
||||
});
|
||||
|
||||
it('stores an empty string once every suite is removed', () => {
|
||||
const onChange = renderSelect('TLS_AES_256_GCM_SHA384');
|
||||
const remove = document.querySelector('.ant-select-selection-item-remove');
|
||||
expect(remove).not.toBeNull();
|
||||
fireEvent.click(remove as Element);
|
||||
expect(onChange).toHaveBeenLastCalledWith('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import ClientBulkAddModal from '@/pages/clients/ClientBulkAddModal';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { chooseSelectOption, renderWithProviders } from './test-utils';
|
||||
|
||||
const { bulkCreate } = vi.hoisted(() => ({ bulkCreate: vi.fn() }));
|
||||
vi.mock('@/hooks/useClients', () => ({ useClients: () => ({ bulkCreate }) }));
|
||||
|
||||
it('keeps bulk renewal disabled by default and requires explicit cutoff selection without changing first-use duration', async () => {
|
||||
bulkCreate.mockResolvedValue(new Msg(true, '', { created: 1, skipped: [] }));
|
||||
const post = vi.spyOn(HttpUtil, 'post').mockImplementation(async (url, body) => {
|
||||
if (url !== '/panel/api/clients/renewalPreview')
|
||||
return new Msg(true, '', { datepicker: 'gregorian' });
|
||||
const request = body as { expiryTime: number };
|
||||
return new Msg(true, '', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
renewAt: '',
|
||||
validThrough: '',
|
||||
nextExpiry: '',
|
||||
suggestedExpiryTime: 1893427200000,
|
||||
suggestedExpiry: '2030-01-01T00:00:00+08:00',
|
||||
renewals: 0,
|
||||
canRenew: false,
|
||||
delayedStart: request.expiryTime < 0,
|
||||
});
|
||||
});
|
||||
async function submit() {
|
||||
const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
|
||||
if (!button) throw new Error('Create button missing');
|
||||
await waitFor(() => expect(button.classList.contains('ant-btn-loading')).toBe(false));
|
||||
fireEvent.click(button);
|
||||
}
|
||||
try {
|
||||
renderWithProviders(
|
||||
<ClientBulkAddModal
|
||||
open
|
||||
inbounds={[{ id: 1, protocol: 'vless', tag: 'calendar' }]}
|
||||
onOpenChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select all' }));
|
||||
const mode = screen.getByLabelText('Auto renewal');
|
||||
expect(mode.closest('.ant-select')?.textContent).toContain('Disabled');
|
||||
expect(post.mock.calls.some(([url]) => url === '/panel/api/clients/renewalPreview')).toBe(
|
||||
false,
|
||||
);
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(bulkCreate).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 0,
|
||||
expiryTime: 0,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
chooseSelectOption(mode.id, 'Calendar weekly');
|
||||
await waitFor(() => expect(document.body.textContent).toContain('An expiry must be set'));
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(bulkCreate).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 1,
|
||||
expiryTime: 0,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Set first cycle cutoff/ }));
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(bulkCreate).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ resetWeekday: 1, expiryTime: 1893427200000 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const label = Array.from(document.querySelectorAll('.ant-form-item-label label')).find(
|
||||
(el) => el.textContent === 'Start After First Use',
|
||||
);
|
||||
const toggle = label?.closest('.ant-form-item')?.querySelector('[role="switch"]');
|
||||
if (!toggle) throw new Error('First-use switch missing');
|
||||
fireEvent.click(toggle);
|
||||
const daysLabel = Array.from(document.querySelectorAll('.ant-form-item-label label')).find(
|
||||
(el) => el.textContent === 'Duration (days)',
|
||||
);
|
||||
const daysInput = daysLabel?.closest('.ant-form-item')?.querySelector('input');
|
||||
if (!daysInput) throw new Error('First-use days input missing');
|
||||
fireEvent.change(daysInput, { target: { value: '7' } });
|
||||
await waitFor(() =>
|
||||
expect(document.body.textContent).toContain('Dates are available after first-use activation'),
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Set first cycle cutoff/ })).toBeNull();
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(bulkCreate).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ resetWeekday: 1, expiryTime: -604800000 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
post.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import ClientFormModal from '@/pages/clients/ClientFormModal';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { chooseSelectOption, renderWithProviders } from './test-utils';
|
||||
|
||||
it('preserves monthly clients, previews backend dates, and saves exclusive weekly or disabled modes', async () => {
|
||||
const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue(
|
||||
new Msg(true, '', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
renewAt: '2030-01-01T00:00:00+08:00',
|
||||
validThrough: '2029-12-31T23:59:59+08:00',
|
||||
nextExpiry: '2030-02-01T00:00:00+08:00',
|
||||
suggestedExpiryTime: 1893427200000,
|
||||
suggestedExpiry: '2030-01-01T00:00:00+08:00',
|
||||
renewals: 1,
|
||||
canRenew: true,
|
||||
delayedStart: false,
|
||||
}),
|
||||
);
|
||||
const save = vi.fn().mockResolvedValue(new Msg(true, '', null));
|
||||
async function submit() {
|
||||
const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
|
||||
if (!button) throw new Error('Save button missing');
|
||||
await waitFor(() => expect(button.classList.contains('ant-btn-loading')).toBe(false));
|
||||
fireEvent.click(button);
|
||||
}
|
||||
try {
|
||||
renderWithProviders(
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={{
|
||||
email: 'monthly@example.com',
|
||||
uuid: '11111111-1111-1111-1111-111111111111',
|
||||
subId: 'calendar-sub',
|
||||
enable: true,
|
||||
expiryTime: 1893427200000,
|
||||
resetDay: 1,
|
||||
reset: 7,
|
||||
resetMax: 3,
|
||||
traffic: { resetCount: 2 },
|
||||
}}
|
||||
attachedIds={[1]}
|
||||
inbounds={[{ id: 1, protocol: 'vless', tag: 'calendar' }]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
const mode = screen.getByLabelText('Auto renewal');
|
||||
expect(mode.closest('.ant-select')?.textContent).toContain('Calendar monthly');
|
||||
await waitFor(() => expect(document.body.textContent).toContain('2029-12-31T23:59:59+08:00'));
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/panel/api/clients/renewalPreview',
|
||||
expect.objectContaining({ resetMax: 3, resetCount: 2 }),
|
||||
expect.anything(),
|
||||
);
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reset: 7,
|
||||
resetDay: 1,
|
||||
resetWeekday: 0,
|
||||
expiryTime: 1893427200000,
|
||||
}),
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
chooseSelectOption(mode.id, 'Calendar weekly');
|
||||
const weekday = screen.getByLabelText('Renew on weekday');
|
||||
chooseSelectOption(weekday.id, 'Sunday');
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/panel/api/clients/renewalPreview',
|
||||
expect.objectContaining({ reset: 0, resetDay: 0, resetWeekday: 7 }),
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 7,
|
||||
expiryTime: 1893427200000,
|
||||
}),
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
chooseSelectOption(mode.id, 'Disabled');
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 0,
|
||||
expiryTime: 1893427200000,
|
||||
}),
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
post.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -116,6 +116,11 @@ function renderSubject(overrides: Partial<SubjectProps> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// Opening fetches sub links with no visible loading state; settle it inside act().
|
||||
async function settleSubLinks() {
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
function selectVariant(name: 'Standard' | 'Happ') {
|
||||
fireEvent.click(screen.getByRole('radio', { name: name === 'Happ' ? /Happ/ : name }));
|
||||
}
|
||||
@@ -129,8 +134,9 @@ describe('ClientQrModal Happ presentation', () => {
|
||||
vi.mocked(HttpUtil.post).mockReset();
|
||||
});
|
||||
|
||||
it('opens on Standard without generating a Happ link', () => {
|
||||
it('opens on Standard without generating a Happ link', async () => {
|
||||
renderSubject();
|
||||
await settleSubLinks();
|
||||
|
||||
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
||||
true,
|
||||
@@ -139,8 +145,9 @@ describe('ClientQrModal Happ presentation', () => {
|
||||
expect(HttpUtil.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the Happ option as an encrypted link', () => {
|
||||
it('names the Happ option as an encrypted link', async () => {
|
||||
renderSubject();
|
||||
await settleSubLinks();
|
||||
|
||||
expect(screen.getByRole('radio', { name: HAPP_OPTION_LABEL })).toBeTruthy();
|
||||
});
|
||||
@@ -148,7 +155,7 @@ describe('ClientQrModal Happ presentation', () => {
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['false', false],
|
||||
])('marks the selectable Happ option as locked when the gate is %s', (_name, gate) => {
|
||||
])('marks the selectable Happ option as locked when the gate is %s', async (_name, gate) => {
|
||||
const subSettings: TestSubSettings = {
|
||||
enable: SUB_SETTINGS.enable,
|
||||
subURI: SUB_SETTINGS.subURI,
|
||||
@@ -158,6 +165,7 @@ describe('ClientQrModal Happ presentation', () => {
|
||||
if (gate !== undefined) subSettings.happLinkEnable = gate;
|
||||
|
||||
renderSubject({ subSettings });
|
||||
await settleSubLinks();
|
||||
|
||||
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
||||
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
|
||||
@@ -176,7 +184,7 @@ describe('ClientQrModal Happ presentation', () => {
|
||||
['false', false],
|
||||
])(
|
||||
'replaces the blank Happ content with a persistent empty state when the gate is %s',
|
||||
(_name, gate) => {
|
||||
async (_name, gate) => {
|
||||
const subSettings: TestSubSettings = {
|
||||
enable: SUB_SETTINGS.enable,
|
||||
subURI: SUB_SETTINGS.subURI,
|
||||
@@ -186,6 +194,7 @@ describe('ClientQrModal Happ presentation', () => {
|
||||
if (gate !== undefined) subSettings.happLinkEnable = gate;
|
||||
|
||||
renderSubject({ subSettings });
|
||||
await settleSubLinks();
|
||||
|
||||
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
||||
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
|
||||
@@ -222,8 +231,9 @@ describe('ClientQrModal Happ presentation', () => {
|
||||
expect(screen.queryByRole('tooltip')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the QR modal and deep-links to Happ settings without generating', () => {
|
||||
it('closes the QR modal and deep-links to Happ settings without generating', async () => {
|
||||
const view = renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
||||
await settleSubLinks();
|
||||
selectVariant('Happ');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
|
||||
@@ -411,6 +421,7 @@ describe('ClientQrModal Happ presentation', () => {
|
||||
|
||||
view.update({ open: false });
|
||||
view.update({ open: true });
|
||||
await settleSubLinks();
|
||||
|
||||
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
||||
true,
|
||||
|
||||
@@ -18,7 +18,16 @@ describe('client enable toggle', () => {
|
||||
const email = 'scheduled@example.com';
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(
|
||||
new Msg(true, '', {
|
||||
client: { email, enable: !enable, trafficReset: 'monthly', trafficResetDay: 15 },
|
||||
client: {
|
||||
email,
|
||||
enable: !enable,
|
||||
trafficReset: 'monthly',
|
||||
trafficResetDay: 15,
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 7,
|
||||
resetMax: 3,
|
||||
},
|
||||
inboundIds: [],
|
||||
}),
|
||||
);
|
||||
@@ -44,7 +53,16 @@ describe('client enable toggle', () => {
|
||||
expect(HttpUtil.get).toHaveBeenCalledWith('/panel/api/clients/get/scheduled%40example.com');
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/panel/api/clients/update/scheduled%40example.com',
|
||||
expect.objectContaining({ email, enable, trafficReset: 'monthly', trafficResetDay: 15 }),
|
||||
expect.objectContaining({
|
||||
email,
|
||||
enable,
|
||||
trafficReset: 'monthly',
|
||||
trafficResetDay: 15,
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 7,
|
||||
resetMax: 3,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -58,8 +58,9 @@ describe('clients table row cells', () => {
|
||||
expect(afterFirstRender).toBeGreaterThan(0);
|
||||
|
||||
// Three simulated traffic pushes: the parent re-renders, the props do not change.
|
||||
for (let i = 0; i < 3; i++) bump();
|
||||
await Promise.resolve();
|
||||
act(() => {
|
||||
for (let i = 0; i < 3; i++) bump();
|
||||
});
|
||||
|
||||
expect(reads.count).toBe(afterFirstRender);
|
||||
});
|
||||
@@ -113,7 +114,9 @@ describe('clients table row cells', () => {
|
||||
</Harness>,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 3; i++) bump();
|
||||
act(() => {
|
||||
for (let i = 0; i < 3; i++) bump();
|
||||
});
|
||||
|
||||
// Queried by position rather than label: the suite loads the real en-US
|
||||
// bundle, so the aria-labels are translated strings, not keys. Order is
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('CommandPalette component', () => {
|
||||
});
|
||||
|
||||
// Wait past the 300ms debounce interval while bob fetch is still pending
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 350)));
|
||||
|
||||
// Stale Alice row must STILL not be rendered
|
||||
expect(screen.queryByText('alice@example.com')).toBeNull();
|
||||
@@ -258,7 +258,7 @@ describe('CommandPalette component', () => {
|
||||
|
||||
// Add trailing whitespace
|
||||
fireEvent.change(input, { target: { value: 'abc ' } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 350)));
|
||||
|
||||
// No extra search call because trimmed query has not changed
|
||||
const callsAfterAbcSpace = getSpy.mock.calls.filter((c) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -25,7 +25,8 @@ vi.mock('persian-calendar-suite', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => setDatepicker('gregorian'));
|
||||
// Runs before the shared cleanup(), so the picker is still mounted and re-renders.
|
||||
afterEach(() => act(() => setDatepicker('gregorian')));
|
||||
|
||||
function openPicker(): void {
|
||||
const input = document.querySelector('.ant-picker input');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -6,15 +6,21 @@ import { AllSetting } from '@/models/setting';
|
||||
import GeneralTab from '@/pages/settings/GeneralTab';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
// Mounting fetches inbound options with no visible change; settle it inside act().
|
||||
async function renderGeneralTab(updateSetting: (patch: Partial<AllSetting>) => void) {
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
describe('GeneralTab', () => {
|
||||
it('keeps the stored page size when the field is cleared', () => {
|
||||
it('keeps the stored page size when the field is cleared', async () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await renderGeneralTab(updateSetting);
|
||||
|
||||
const pageSizeInput = screen.getByDisplayValue('25');
|
||||
fireEvent.change(pageSizeInput, { target: { value: '' } });
|
||||
@@ -24,14 +30,10 @@ describe('GeneralTab', () => {
|
||||
expect((pageSizeInput as HTMLInputElement).value).toBe('25');
|
||||
});
|
||||
|
||||
it('forwards typed page sizes unchanged, zero included', () => {
|
||||
it('forwards typed page sizes unchanged, zero included', async () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await renderGeneralTab(updateSetting);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -209,7 +209,7 @@ describe('GeoBrowserModal selection', () => {
|
||||
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
|
||||
await user.click(screen.getByText('telegram'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 800)));
|
||||
expect(entryFilters(get)).toEqual(['', '']);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
import { buildHappPresetDeeplink, toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
|
||||
describe('Happ presets and helpers', () => {
|
||||
it('correctly parses comma and newline separated lists', () => {
|
||||
const raw = 'domain:ir\nregexp:.*\\.ir$\n, example.com, , google.com';
|
||||
const parsed = parseList(raw);
|
||||
expect(parsed).toEqual(['domain:ir', 'regexp:.*\\.ir$', 'example.com', 'google.com']);
|
||||
});
|
||||
|
||||
it('generates valid happ://routing/off for off preset', () => {
|
||||
const link = buildHappPresetDeeplink('off');
|
||||
expect(link).toBe('happ://routing/off');
|
||||
@@ -26,7 +20,7 @@ describe('Happ presets and helpers', () => {
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toContain('domain:ir');
|
||||
expect(parsed.DirectIp).toContain('geoip:ir');
|
||||
expect(parsed.BlockSites).toContain('geosite:category-ads-all');
|
||||
expect(parsed.BlockSites).toEqual([]);
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for china-direct preset', () => {
|
||||
@@ -37,28 +31,92 @@ describe('Happ presets and helpers', () => {
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed.Name).toBe('China Direct');
|
||||
expect(parsed.DirectSites).toContain('geosite:cn');
|
||||
expect(parsed.DirectIp).toContain('geoip:cn');
|
||||
expect(parsed).toEqual({
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for adblock preset', () => {
|
||||
const link = buildHappPresetDeeplink('adblock');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
it.each(['iran-bypass', 'china-direct', 'global', 'lan-bypass'])(
|
||||
'adds ad blocking only when opted in for %s without changing its routing',
|
||||
(preset) => {
|
||||
const decode = (link: string) => JSON.parse(atob(link.replace('happ://routing/onadd/', '')));
|
||||
const base = decode(buildHappPresetDeeplink(preset));
|
||||
const withAds = decode(buildHappPresetDeeplink(preset, true));
|
||||
const withoutAds = decode(buildHappPresetDeeplink(preset, false));
|
||||
|
||||
expect(parsed.Name).toBe('AdBlock');
|
||||
expect(parsed.BlockSites).toContain('geosite:category-ads-all');
|
||||
expect(base.BlockSites).toEqual([]);
|
||||
expect(withAds.BlockSites).toEqual(['geosite:category-ads-all']);
|
||||
expect({ ...withAds, BlockSites: [] }).toEqual(base);
|
||||
expect(withoutAds).toEqual(base);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps routing disabled even when ad blocking is selected', () => {
|
||||
expect(buildHappPresetDeeplink('off', true)).toBe('happ://routing/off');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for global preset', () => {
|
||||
it.each(['adblock', 'unknown'])(
|
||||
'does not generate a profile for unsupported preset %s',
|
||||
(preset) => {
|
||||
expect(buildHappPresetDeeplink(preset)).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the global preset without direct exceptions', () => {
|
||||
const link = buildHappPresetDeeplink('global');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed.Name).toBe('Global Proxy');
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toEqual([]);
|
||||
expect(parsed.DirectIp).toEqual([]);
|
||||
expect(parsed.DomainStrategy).toBe('AsIs');
|
||||
});
|
||||
|
||||
it('generates a separate LAN bypass preset with proxy as the default', () => {
|
||||
const link = buildHappPresetDeeplink('lan-bypass');
|
||||
const prefix = 'happ://routing/onadd/';
|
||||
expect(link.startsWith(prefix)).toBe(true);
|
||||
const parsed = JSON.parse(atob(link.slice(prefix.length)));
|
||||
|
||||
expect(parsed.Name).toBe('Global Bypass Local Network');
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toEqual(['geosite:private']);
|
||||
expect(parsed.DirectIp).toEqual(['geoip:private']);
|
||||
expect(parsed.BlockSites).toEqual([]);
|
||||
expect(parsed.DomainStrategy).toBe('AsIs');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildHappRoutingDeeplink,
|
||||
loadHappRouting,
|
||||
parseHappRoutingJson,
|
||||
parseHappRoutingList,
|
||||
} from '@/pages/settings/happRoutingEditor';
|
||||
|
||||
const profile = {
|
||||
Name: '大陆 · فارسی 🛣',
|
||||
GlobalProxy: 'false',
|
||||
RouteOrder: 'direct-proxy-block',
|
||||
DomainStrategy: 'AsIs',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
DnsHosts: { 'dns.alidns.com': '223.5.5.5' },
|
||||
DirectSites: ['geosite:cn', 'regexp:^host[0-9]{1,3}\\.example$'],
|
||||
DirectIp: ['geoip:cn'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
FutureSetting: { enabled: true, values: ['保留', 7, null] },
|
||||
};
|
||||
|
||||
describe('Happ routing editor profile loading', () => {
|
||||
it.each(['onadd', 'add'] as const)('loads %s with UTF-8 and preserves every field', (mode) => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString('base64');
|
||||
expect(loadHappRouting(` happ://routing/${mode}/${encoded}\n`)).toEqual({
|
||||
success: true,
|
||||
profile,
|
||||
mode,
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['base64', 'base64url'] as const)(
|
||||
'accepts padded and unpadded %s payloads',
|
||||
(encoding) => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString(encoding);
|
||||
const unpadded = encoded.replace(/=+$/, '');
|
||||
const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=');
|
||||
for (const payload of [padded, unpadded]) {
|
||||
const loaded = loadHappRouting(`happ://routing/onadd/${payload}`);
|
||||
expect(loaded).toMatchObject({ success: true, profile });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('loads raw JSON without filling absent fields or discarding extensions', () => {
|
||||
const input = '{"Name":"existing","GlobalProxy":"false","Future":{"nested":[1,null]}}';
|
||||
expect(loadHappRouting(input)).toEqual({
|
||||
success: true,
|
||||
profile: { Name: 'existing', GlobalProxy: 'false', Future: { nested: [1, null] } },
|
||||
mode: 'onadd',
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['json', 'add', 'onadd'] as const)('preserves null lists in a %s profile', (format) => {
|
||||
const nullableProfile = {
|
||||
...profile,
|
||||
DirectSites: null,
|
||||
DirectIp: null,
|
||||
ProxySites: null,
|
||||
ProxyIp: null,
|
||||
BlockSites: null,
|
||||
BlockIp: null,
|
||||
};
|
||||
const json = JSON.stringify(nullableProfile);
|
||||
const source =
|
||||
format === 'json'
|
||||
? json
|
||||
: `happ://routing/${format}/${Buffer.from(json, 'utf8').toString('base64')}`;
|
||||
|
||||
expect(loadHappRouting(source)).toEqual({
|
||||
success: true,
|
||||
profile: nullableProfile,
|
||||
mode: format === 'json' ? 'onadd' : format,
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('retains the existing new-profile flow only for an empty source', () => {
|
||||
expect(loadHappRouting(' \n ')).toEqual({
|
||||
success: true,
|
||||
profile: {
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
},
|
||||
mode: 'onadd',
|
||||
isNew: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['happ://routing/off', 'off'],
|
||||
['https://example.com/rules', 'remote'],
|
||||
[' \nHTTPS://example.com/rules\t ', 'remote'],
|
||||
['http://example.com/rules', 'invalid'],
|
||||
['https://', 'invalid'],
|
||||
['https://example.com/rules with spaces', 'invalid'],
|
||||
['https://example.com/rules\nsecond-line', 'invalid'],
|
||||
['happ://routing/onadd/', 'invalid'],
|
||||
['happ://routing/onadd/%invalid', 'invalid'],
|
||||
['happ://routing/onadd/e3\n0=', 'invalid'],
|
||||
['happ://routing/add//w==', 'invalid'],
|
||||
['happ://routing/onadd/W10=', 'invalid'],
|
||||
['happ://unknown/e30=', 'invalid'],
|
||||
['{"DirectSites":"geosite:cn"}', 'invalid'],
|
||||
['{"DirectIp":[3]}', 'invalid'],
|
||||
['{"Name":', 'invalid'],
|
||||
])('does not replace an unloadable source %s with a new profile', (source, error) => {
|
||||
expect(loadHappRouting(source)).toEqual({ success: false, error });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Happ routing editor JSON and output', () => {
|
||||
it('preserves all fields through a valid JSON edit', () => {
|
||||
expect(parseHappRoutingJson(JSON.stringify(profile))).toEqual(profile);
|
||||
});
|
||||
|
||||
it('preserves extension keys as JSON data without changing object prototypes', () => {
|
||||
const source = '{"Name":"extensions","__proto__":{"clientOption":true},"DirectSites":[]}';
|
||||
const parsed = parseHappRoutingJson(source);
|
||||
expect(parsed).not.toBeNull();
|
||||
if (!parsed) throw new Error('Expected a valid profile');
|
||||
|
||||
const rebuilt = JSON.parse(atob(buildHappRoutingDeeplink(parsed).split('/onadd/')[1]));
|
||||
expect(rebuilt).toEqual(JSON.parse(source));
|
||||
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it.each(['', '{', '[]', 'null', 'true', '42', '{"BlockIp":[null]}', '{"ProxySites":[{}]}'])(
|
||||
'rejects an invalid profile %s',
|
||||
(source) => {
|
||||
expect(parseHappRoutingJson(source)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('generates a standard UTF-8 link while retaining add semantics and unknown fields', () => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString('base64');
|
||||
expect(buildHappRoutingDeeplink(profile, 'add')).toBe(`happ://routing/add/${encoded}`);
|
||||
expect(buildHappRoutingDeeplink(profile)).toBe(`happ://routing/onadd/${encoded}`);
|
||||
});
|
||||
|
||||
it('keeps regexp commas intact when editing one rule per line', () => {
|
||||
expect(
|
||||
parseHappRoutingList(' geosite:cn\r\nregexp:^host[0-9]{1,3}\\.example$\n\n domain:local '),
|
||||
).toEqual(['geosite:cn', 'regexp:^host[0-9]{1,3}\\.example$', 'domain:local']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { EditorView } from 'codemirror';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||
import { toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const profile = {
|
||||
Name: '当前草稿',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSDomain: 'https://dns.example/dns-query',
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
DnsHosts: { 'dns.example': '1.1.1.1' },
|
||||
DirectSites: ['geosite:cn', 'regexp:^example{1,2}\\.com$'],
|
||||
ProxySites: ['geosite:google'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
DirectIp: ['geoip:cn'],
|
||||
ProxyIp: ['1.1.1.1/32'],
|
||||
BlockIp: ['0.0.0.0/8'],
|
||||
FutureSetting: { nested: [{ enabled: false, count: 0, text: '保留' }] },
|
||||
};
|
||||
|
||||
const nullableProfile = {
|
||||
...profile,
|
||||
DirectSites: null,
|
||||
DirectIp: null,
|
||||
ProxySites: null,
|
||||
ProxyIp: null,
|
||||
BlockSites: null,
|
||||
BlockIp: null,
|
||||
};
|
||||
|
||||
function routingLink(value: Record<string, unknown>, mode = 'onadd') {
|
||||
return `happ://routing/${mode}/${toBase64Utf8(JSON.stringify(value))}`;
|
||||
}
|
||||
|
||||
function renderSettings(input = routingLink(profile)) {
|
||||
const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
|
||||
|
||||
function SettingsHarness() {
|
||||
const [allSetting, setAllSetting] = useState(() =>
|
||||
Object.assign(new AllSetting(), { subRoutingRules: input }),
|
||||
);
|
||||
|
||||
return (
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={(patch) => {
|
||||
updateSetting(patch);
|
||||
setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
|
||||
}}
|
||||
isMobile={false}
|
||||
remoteSourceBadge={() => null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SettingsHarness />);
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Visual Rule Generator' }));
|
||||
return within(screen.getByRole('dialog'));
|
||||
}
|
||||
|
||||
function routingRules() {
|
||||
return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function generatedProfile(): Record<string, unknown> {
|
||||
const payload = routingRules().replace(/^happ:\/\/routing\/(?:onadd|add)\//, '');
|
||||
return JSON.parse(
|
||||
new TextDecoder().decode(Uint8Array.from(atob(payload), (char) => char.charCodeAt(0))),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function basicField(name: string) {
|
||||
return screen.getByRole('textbox', { name }) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function jsonEditor() {
|
||||
const content = screen.getByRole('textbox', { name: 'JSON editor' });
|
||||
const editor = EditorView.findFromDOM(content);
|
||||
if (!editor) throw new Error('CodeMirror editor not mounted');
|
||||
return editor;
|
||||
}
|
||||
|
||||
function changeJson(value: string) {
|
||||
const editor = jsonEditor();
|
||||
act(() => {
|
||||
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } });
|
||||
});
|
||||
}
|
||||
|
||||
describe('Happ routing editor', () => {
|
||||
it('loads the current unsaved routing draft when opened', () => {
|
||||
const updateSetting = renderSettings();
|
||||
const latest = { ...profile, DirectSites: ['domain:latest-draft.example'] };
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
|
||||
target: { value: routingLink(latest) },
|
||||
});
|
||||
updateSetting.mockClear();
|
||||
|
||||
const dialog = openEditor();
|
||||
|
||||
expect((dialog.getAllByRole('textbox')[0] as HTMLTextAreaElement).value).toBe(
|
||||
'domain:latest-draft.example',
|
||||
);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['add', 'onadd'])(
|
||||
'preserves other fields and %s semantics when editing basic rules',
|
||||
(mode) => {
|
||||
const updateSetting = renderSettings(routingLink(profile, mode));
|
||||
openEditor();
|
||||
const edited = 'regexp:^other{1,2}\\.com$\n domain:another.example\n';
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), { target: { value: edited } });
|
||||
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe(edited);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(routingRules().startsWith(`happ://routing/${mode}/`)).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
...profile,
|
||||
DirectSites: ['regexp:^other{1,2}\\.com$', 'domain:another.example'],
|
||||
});
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('syncs both tabs while retaining full-profile edits', () => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Blocked Domains (Ad/Malware)'), {
|
||||
target: { value: 'geosite:category-ads-all\ndomain:ads.example' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
const advanced = JSON.parse(jsonEditor().state.doc.toString()) as Record<string, unknown>;
|
||||
expect(advanced).toEqual({
|
||||
...profile,
|
||||
BlockSites: ['geosite:category-ads-all', 'domain:ads.example'],
|
||||
});
|
||||
|
||||
const next = { ...advanced, ProxyIp: ['9.9.9.9/32'], FutureSetting: { deep: ['new'] } };
|
||||
changeJson(JSON.stringify(next, null, 2));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Proxy IPs / CIDRs').value).toBe('9.9.9.9/32');
|
||||
expect(basicField('Blocked Domains (Ad/Malware)').value).toBe(
|
||||
'geosite:category-ads-all\ndomain:ads.example',
|
||||
);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(next);
|
||||
});
|
||||
|
||||
it.each(['Basic rules', 'Advanced editor'])(
|
||||
'preserves untouched null lists when generating from %s',
|
||||
(tab) => {
|
||||
renderSettings(JSON.stringify(nullableProfile));
|
||||
const dialog = openEditor();
|
||||
expect(dialog.queryByRole('alert')).toBeNull();
|
||||
expect(
|
||||
dialog.getAllByRole('textbox').map((field) => (field as HTMLTextAreaElement).value),
|
||||
).toEqual(['', '', '', '', '', '']);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(nullableProfile);
|
||||
fireEvent.click(screen.getByRole('tab', { name: tab }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(generatedProfile()).toEqual(nullableProfile);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('replaces only the edited null list with rules', () => {
|
||||
renderSettings(routingLink(nullableProfile, 'add'));
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'domain:local.example\n' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(routingRules().startsWith('happ://routing/add/')).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
...nullableProfile,
|
||||
DirectSites: ['domain:local.example'],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts setting a list to null in the advanced editor', () => {
|
||||
renderSettings();
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
const next = { ...profile, DirectSites: null };
|
||||
changeJson(JSON.stringify(next));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe('');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(generatedProfile()).toEqual(next);
|
||||
});
|
||||
|
||||
it.each(['{"Name":', '{"DirectSites": [42]}'])(
|
||||
'retains invalid JSON %s until repaired',
|
||||
(invalid) => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
changeJson(invalid);
|
||||
|
||||
const generate = screen.getByRole('button', {
|
||||
name: 'Generate Deeplink',
|
||||
}) as HTMLButtonElement;
|
||||
expect(generate.disabled).toBe(true);
|
||||
expect(screen.getByRole('alert').textContent).toContain('arrays of strings');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Advanced editor' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(jsonEditor().state.doc.toString()).toBe(invalid);
|
||||
fireEvent.click(generate);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
|
||||
const repaired = { ...profile, Name: 'Repaired', BlockIp: ['192.0.2.0/24'] };
|
||||
changeJson(JSON.stringify(repaired));
|
||||
expect(generate.disabled).toBe(false);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Blocked IPs / CIDRs').value).toBe('192.0.2.0/24');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(repaired);
|
||||
},
|
||||
);
|
||||
|
||||
it('discards canceled edits and reloads the latest parent draft on reopening', () => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'discard.example' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
changeJson('{invalid');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe(routingLink(profile));
|
||||
|
||||
const latest = { ...profile, DirectSites: ['latest.example'] };
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
|
||||
target: { value: JSON.stringify(latest) },
|
||||
});
|
||||
updateSetting.mockClear();
|
||||
openEditor();
|
||||
expect(screen.getByRole('tab', { name: 'Basic rules' }).getAttribute('aria-selected')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe('latest.example');
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(latest);
|
||||
expect(updateSetting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://example.com/DEFAULT.DEEPLINK', 'Remote URLs cannot be loaded here'],
|
||||
['http://example.com/rules', 'not a valid Happ routing link'],
|
||||
['happ://routing/off', 'Routing is disabled'],
|
||||
['happ://routing/onadd/not-base64', 'not a valid Happ routing link'],
|
||||
['{"BlockIp":42}', 'not a valid Happ routing link'],
|
||||
])('blocks generation for unsupported current rules: %s', (input, message) => {
|
||||
const updateSetting = renderSettings(input);
|
||||
openEditor();
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain(message);
|
||||
const generate = screen.getByRole('button', { name: 'Generate Deeplink' }) as HTMLButtonElement;
|
||||
expect(generate.disabled).toBe(true);
|
||||
fireEvent.click(generate);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe(input);
|
||||
});
|
||||
|
||||
it('starts a new custom profile when the current draft is blank', () => {
|
||||
const updateSetting = renderSettings('');
|
||||
openEditor();
|
||||
expect(screen.getByRole('alert').textContent).toContain('No current rules');
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'domain:local.example\n' },
|
||||
});
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(routingRules().startsWith('happ://routing/onadd/')).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['domain:local.example'],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add absent list fields to an unchanged raw JSON profile', () => {
|
||||
const minimal = { Name: 'Minimal', FutureSetting: profile.FutureSetting };
|
||||
renderSettings(JSON.stringify(minimal));
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(minimal);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(minimal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const chinaProfile = {
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
};
|
||||
|
||||
function renderSettings(isMobile = false) {
|
||||
const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
|
||||
|
||||
function SettingsHarness() {
|
||||
const [allSetting, setAllSetting] = useState(() => {
|
||||
const settings = new AllSetting();
|
||||
settings.subRoutingRules = 'https://example.com/existing-routing';
|
||||
return settings;
|
||||
});
|
||||
|
||||
return (
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={(patch) => {
|
||||
updateSetting(patch);
|
||||
setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
remoteSourceBadge={() => null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SettingsHarness />);
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
function openPresets() {
|
||||
const select = screen.getByRole('combobox').closest('.ant-select');
|
||||
if (!select) throw new Error('Routing preset select not found');
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
return Array.from(
|
||||
document.querySelectorAll(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function choosePreset(label: string) {
|
||||
const option = openPresets().find((item) => item.textContent === label);
|
||||
if (!option) throw new Error(`Routing preset '${label}' not found`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
function routingRules() {
|
||||
return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function routingProfile(): Record<string, unknown> {
|
||||
const prefix = 'happ://routing/onadd/';
|
||||
const link = routingRules();
|
||||
expect(link.startsWith(prefix)).toBe(true);
|
||||
return JSON.parse(atob(link.slice(prefix.length))) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('Happ routing preset controls', () => {
|
||||
it('keeps selecting and toggling local until Apply updates the routing rules', () => {
|
||||
const updateSetting = renderSettings();
|
||||
|
||||
choosePreset('China Direct (Bypass-CN)');
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
expect(includeAdblock.getAttribute('aria-checked')).toBe('false');
|
||||
fireEvent.click(includeAdblock);
|
||||
|
||||
expect(includeAdblock.getAttribute('aria-checked')).toBe('true');
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe('https://example.com/existing-routing');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
|
||||
expect(routingProfile().BlockSites).toEqual(['geosite:category-ads-all']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Iran Bypass', 'Iran Bypass'],
|
||||
['China Direct (Bypass-CN)', 'Bypass-CN'],
|
||||
['Full Proxy', 'Global Proxy'],
|
||||
['Global Bypass Local Network', 'Global Bypass Local Network'],
|
||||
])('applies %s without AdBlock by default', (label, profileName) => {
|
||||
renderSettings();
|
||||
choosePreset(label);
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Include AdBlock' }).getAttribute('aria-checked'),
|
||||
).toBe('false');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({ Name: profileName, BlockSites: [] });
|
||||
});
|
||||
|
||||
it('keeps LAN bypass separate from Full Proxy when switching presets', () => {
|
||||
renderSettings();
|
||||
choosePreset('Global Bypass Local Network');
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Include AdBlock' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({
|
||||
Name: 'Global Bypass Local Network',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:private'],
|
||||
DirectIp: ['geoip:private'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
|
||||
choosePreset('Full Proxy');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({
|
||||
Name: 'Global Proxy',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves China routing when opting in and clears AdBlock when reapplied after opting out', () => {
|
||||
const updateSetting = renderSettings(true);
|
||||
choosePreset('China Direct (Bypass-CN)');
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
fireEvent.click(includeAdblock);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toEqual({
|
||||
...chinaProfile,
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
const withAdblock = routingRules();
|
||||
|
||||
fireEvent.click(includeAdblock);
|
||||
expect(routingRules()).toBe(withAdblock);
|
||||
expect(updateSetting).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledTimes(2);
|
||||
expect(routingProfile()).toEqual(chinaProfile);
|
||||
});
|
||||
|
||||
it('offers only base presets so AdBlock cannot replace a country preset', () => {
|
||||
renderSettings();
|
||||
|
||||
expect(openPresets().map((option) => option.textContent)).toEqual([
|
||||
'Iran Bypass',
|
||||
'China Direct (Bypass-CN)',
|
||||
'Full Proxy',
|
||||
'Global Bypass Local Network',
|
||||
'Disable Routing (happ://routing/off)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables AdBlock for Off and applies the off link even after opting in', () => {
|
||||
const updateSetting = renderSettings();
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
fireEvent.click(includeAdblock);
|
||||
choosePreset('Disable Routing (happ://routing/off)');
|
||||
|
||||
expect((includeAdblock as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({
|
||||
subRoutingRules: 'happ://routing/off',
|
||||
});
|
||||
expect(routingRules()).toBe('happ://routing/off');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, onTestFinished, vi } from 'vitest';
|
||||
import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
|
||||
@@ -279,6 +279,8 @@ describe('InboundFormModal', () => {
|
||||
const post = vi.mocked(HttpUtil.post);
|
||||
post.mockClear();
|
||||
messageError.mockClear();
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
onTestFinished(() => consoleError.mockRestore());
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
|
||||
@@ -294,6 +296,10 @@ describe('InboundFormModal', () => {
|
||||
expect.stringContaining('TLS certificate 1: Import a TLS certificate'),
|
||||
);
|
||||
});
|
||||
expect(consoleError).toHaveBeenCalledWith('[InboundFormModal] schema validation failed:', [
|
||||
'TLS certificate 1: Import a TLS certificate or enter its file path before saving',
|
||||
'TLS certificate 1: Import the TLS private key or enter its file path before saving',
|
||||
]);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,13 +9,17 @@ const RULE = 'input-number(no-synthetic-clear)';
|
||||
|
||||
function runGuard(target: string): string {
|
||||
try {
|
||||
execFileSync('./node_modules/.bin/oxlint', ['-c', `${FIXTURES}/guard.oxlintrc.json`, target], {
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
// .bin/oxlint is a sh shim Windows can't spawn; run the node entry directly.
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
['node_modules/oxlint/bin/oxlint', '-c', `${FIXTURES}/guard.oxlintrc.json`, target],
|
||||
{ encoding: 'utf8', stdio: 'pipe' },
|
||||
);
|
||||
return '';
|
||||
} catch (error) {
|
||||
return String((error as { stdout?: string }).stdout ?? '');
|
||||
const { status, stdout } = error as { status?: number | null; stdout?: string };
|
||||
if (typeof status !== 'number') throw error;
|
||||
return String(stdout ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import NodeHistoryPanel from '@/pages/nodes/NodeHistoryPanel';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
const plots = vi.hoisted(() => [] as { scales: { y: { range: () => [number, number] } } }[]);
|
||||
|
||||
vi.mock('uplot', () => ({
|
||||
default: class {
|
||||
static paths = { spline: () => undefined };
|
||||
static pxRatio = 1;
|
||||
constructor(opts: (typeof plots)[number]) {
|
||||
plots.push(opts);
|
||||
}
|
||||
setData() {}
|
||||
setSize() {}
|
||||
redraw() {}
|
||||
destroy() {}
|
||||
},
|
||||
}));
|
||||
|
||||
// The net series fell through to Sparkline's percentage defaults: a 0-100 scale
|
||||
// and a "%" label, so 512 KB/s rendered as "512%" far above the chart.
|
||||
describe('NodeHistoryPanel', () => {
|
||||
it('charts net throughput in KB/s on its own scale', async () => {
|
||||
const samples: Record<string, number> = {
|
||||
cpu: 40,
|
||||
mem: 60,
|
||||
netUp: 512 * 1024,
|
||||
netDown: 200 * 1024,
|
||||
};
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||
const metric = url.split('/').at(-2) ?? '';
|
||||
return new Msg(true, '', [{ t: 1_700_000_000, v: samples[metric] }]);
|
||||
});
|
||||
|
||||
render(<NodeHistoryPanel node={{ id: 7 }} />);
|
||||
|
||||
// Sparkline updates its chart refs in a passive effect after the DOM commits.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('img')).toHaveLength(4);
|
||||
expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual([
|
||||
'40%',
|
||||
'60%',
|
||||
'512',
|
||||
'200',
|
||||
]);
|
||||
expect(plots.map((p) => p.scales.y.range())).toEqual([
|
||||
[0, 100],
|
||||
[0, 100],
|
||||
[0, 512 * 1.1],
|
||||
[0, 200 * 1.1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import NordModal from '@/pages/xray/overrides/NordModal';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
@@ -70,7 +70,10 @@ async function chooseOption(testId: string, labelPart: string) {
|
||||
`${item.getAttribute('title') ?? ''} ${item.textContent ?? ''}`.includes(labelPart),
|
||||
);
|
||||
if (!option) throw new Error(`Missing option containing ${labelPart}`);
|
||||
fireEvent.click(option);
|
||||
// Picking a country fetches its servers; let that settle inside act().
|
||||
await act(async () => {
|
||||
fireEvent.click(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function clickAddOutbound() {
|
||||
@@ -239,6 +242,25 @@ describe('NordVPN modal', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lists every server again when All Cities is chosen after a city', async () => {
|
||||
mockNordApi();
|
||||
renderWithProviders(<NordHarness />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
|
||||
await chooseOption('nord-country-select', 'United States');
|
||||
await chooseOption('nord-city-select', 'New York');
|
||||
await chooseOption('nord-city-select', 'All Cities');
|
||||
|
||||
const serverNode = screen.getByTestId('nord-server-select');
|
||||
const serverSelect = serverNode.closest('.ant-select') ?? serverNode;
|
||||
fireEvent.mouseDown(serverSelect.querySelector('.ant-select-selector') ?? serverSelect);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelectorAll<HTMLElement>('.nord-server-popup .ant-select-item-option'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables Add when the selected server is already present', async () => {
|
||||
mockNordApi();
|
||||
renderWithProviders(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import PiaModal from '@/pages/xray/overrides/PiaModal';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
@@ -106,7 +106,10 @@ async function chooseOption(testId: string, labelPart: string) {
|
||||
(item.getAttribute('title') ?? item.textContent ?? '').includes(labelPart),
|
||||
);
|
||||
if (!option) throw new Error(`Missing option containing ${labelPart}`);
|
||||
fireEvent.click(option);
|
||||
// Picking a country fetches its servers; let that settle inside act().
|
||||
await act(async () => {
|
||||
fireEvent.click(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function clickAddOutbound() {
|
||||
@@ -115,7 +118,10 @@ async function clickAddOutbound() {
|
||||
if ((btn as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
|
||||
return btn;
|
||||
});
|
||||
fireEvent.click(addButton);
|
||||
// Adding provisions a key over HTTP; let that settle inside act().
|
||||
await act(async () => {
|
||||
fireEvent.click(addButton);
|
||||
});
|
||||
}
|
||||
|
||||
function expectPiaOutbound(
|
||||
@@ -226,6 +232,25 @@ describe('PIA modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists every server again when All regions is chosen after a region', async () => {
|
||||
mockPiaApi();
|
||||
renderWithProviders(<PiaHarness />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await chooseOption('pia-region-select', 'US West');
|
||||
await chooseOption('pia-region-select', 'All regions');
|
||||
|
||||
const serverNode = screen.getByTestId('pia-server-select');
|
||||
const serverSelect = serverNode.closest('.ant-select') ?? serverNode;
|
||||
fireEvent.mouseDown(serverSelect.querySelector('.ant-select-selector') ?? serverSelect);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
visibleOptions().filter((option) => /useast1|uswest1/.test(option.textContent ?? '')),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables Add when the selected server is already in the list', async () => {
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import RemarkTemplateField from '@/components/form/RemarkTemplateField';
|
||||
import { previewRemark, SUBSCRIPTION_METADATA_VARIABLES } from '@/lib/remark/remarkVariables';
|
||||
@@ -29,7 +29,7 @@ describe('RemarkTemplateField', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RemarkTemplateField value="Hello " onChange={onChange} multiline rows={3} />);
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
act(() => textarea.focus());
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('RuleFormModal edit preserves unsurfaced fields', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
|
||||
const onConfirm = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import enUS from '../../../internal/web/translation/en-US.json';
|
||||
|
||||
// RTL sets this from a global beforeAll, which never runs with `globals: false`.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.mock('persian-calendar-suite', () => ({
|
||||
PersianDateTimePicker: () => null,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
SPONSOR_DISMISS_MS,
|
||||
dismissSponsor,
|
||||
isSponsorDismissed,
|
||||
pickLocale,
|
||||
placementStatus,
|
||||
} from '@/lib/sponsors';
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('pickLocale', () => {
|
||||
const map = { en: 'Hello', fa: 'سلام', 'zh-TW': '哈囉' };
|
||||
|
||||
test.each([
|
||||
['fa-IR', 'سلام'],
|
||||
['zh-TW', '哈囉'],
|
||||
['zh-CN', 'Hello'],
|
||||
['ru-RU', 'Hello'],
|
||||
])('%s resolves to %s', (lang, want) => {
|
||||
expect(pickLocale(map, lang)).toBe(want);
|
||||
});
|
||||
|
||||
test('missing map yields empty string', () => {
|
||||
expect(pickLocale(undefined, 'en-US')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sponsor dismissal', () => {
|
||||
const t0 = 1_800_000_000_000;
|
||||
|
||||
test('hides only the dismissed slot until the window elapses', () => {
|
||||
dismissSponsor('acme', 'sidebar', t0);
|
||||
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
|
||||
expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
|
||||
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
|
||||
});
|
||||
|
||||
test('prunes expired entries on the next dismiss', () => {
|
||||
dismissSponsor('old', 'login', t0);
|
||||
dismissSponsor('new', 'login', t0 + SPONSOR_DISMISS_MS);
|
||||
const stored = JSON.parse(localStorage.getItem('xui.sponsor.dismissed') || '{}');
|
||||
expect(Object.keys(stored)).toEqual(['new:login']);
|
||||
});
|
||||
|
||||
test('corrupt storage is treated as nothing dismissed', () => {
|
||||
localStorage.setItem('xui.sponsor.dismissed', 'not json');
|
||||
expect(isSponsorDismissed('acme', 'sidebar', t0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placementStatus', () => {
|
||||
const sp = (id: string, slots: string[], until: string) => ({
|
||||
id,
|
||||
name: id,
|
||||
slots,
|
||||
until,
|
||||
title: {},
|
||||
text: {},
|
||||
link: 'https://x.example/',
|
||||
});
|
||||
|
||||
test('exclusive slot is taken until the latest booking ends', () => {
|
||||
const sponsors = [
|
||||
sp('a', ['dashboard'], '2026-11-01T00:00:00Z'),
|
||||
sp('b', ['dashboard'], '2026-12-15T00:00:00Z'),
|
||||
];
|
||||
expect(placementStatus(sponsors, 'dashboard')).toEqual({
|
||||
count: 2,
|
||||
capacity: 1,
|
||||
takenUntil: '2026-12-15T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('sidebar is full at three and frees up when the earliest ends', () => {
|
||||
const sponsors = [
|
||||
sp('a', ['sidebar'], '2026-12-01T00:00:00Z'),
|
||||
sp('b', ['sidebar'], '2026-10-20T00:00:00Z'),
|
||||
sp('c', ['sidebar'], '2026-11-10T00:00:00Z'),
|
||||
];
|
||||
expect(placementStatus(sponsors, 'sidebar')).toEqual({
|
||||
count: 3,
|
||||
capacity: 3,
|
||||
takenUntil: '2026-10-20T00:00:00Z',
|
||||
});
|
||||
expect(placementStatus(sponsors.slice(0, 2), 'sidebar')).toEqual({ count: 2, capacity: 3 });
|
||||
});
|
||||
|
||||
test('uncapped page slot reports only a count', () => {
|
||||
expect(placementStatus([sp('a', ['page'], '2026-12-01T00:00:00Z')], 'page')).toEqual({
|
||||
count: 1,
|
||||
capacity: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -286,7 +286,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
|
||||
'^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|sponsors|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
|
||||
'^/$': makeBackendProxy(BACKEND_TARGET),
|
||||
'^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
|
||||
'^/(?:[^/]+/)?ws$': {
|
||||
|
||||
@@ -25,6 +25,8 @@ export default defineConfig({
|
||||
name: 'unit',
|
||||
include: ['src/test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
// msw probes localStorage on load; Node 25+'s file-less one warns per worker.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
setupFiles: ['./src/test/setup.ts', './src/test/setup.msw.ts'],
|
||||
},
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user