chore: merge upstream/main into admin-frontend-domain preserving custom domain and branding features

This commit is contained in:
Bot
2026-08-31 02:56:31 +08:00
114 changed files with 8616 additions and 5337 deletions
+21
View File
@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
npm-dependencies:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
github-actions:
patterns:
- "*"
+4 -4
View File
@@ -10,14 +10,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
with:
bun-version: "latest"
@@ -34,7 +34,7 @@ jobs:
- name: Commit and push changes
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: auto-fix linting and formatting issues"
commit_options: "--no-verify"
@@ -42,7 +42,7 @@ jobs:
- name: Add PR comment
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
+175
View File
@@ -0,0 +1,175 @@
name: E2E
on:
push:
branches:
- main
paths:
- "src/**"
- "tests/e2e/**"
- "playwright.config.ts"
- "package.json"
- "package-lock.json"
- ".github/workflows/e2e.yml"
pull_request:
branches:
- main
jobs:
playwright:
runs-on: ubuntu-latest
timeout-minutes: 25
env:
AGENT_REPO: nezhahq/agent
AGENT_REF: main
NEZHA_REPO: nezhahq/nezha
NEZHA_REF: master
NZ_LISTENHOST: 127.0.0.1
NZ_LISTENPORT: "8008"
NZ_AGENTSECRETKEY: e2e-agent-secret-32-bytes-long-ok
NZ_JWTSECRETKEY: e2e-jwt-secret-key-min-32-chars-long-ok
NZ_SITENAME: nezha-e2e
NZ_DEBUG: "true"
E2E_ADMIN_USER: admin
E2E_ADMIN_PASS: admin
E2E_AGENT_UUID: 11111111-2222-4333-8444-555555555555
E2E_BASE_URL: http://127.0.0.1:5173
steps:
- name: Checkout admin-frontend
uses: actions/checkout@v7
with:
path: admin-frontend
- name: Checkout nezha backend
uses: actions/checkout@v7
with:
repository: ${{ env.NEZHA_REPO }}
ref: ${{ env.NEZHA_REF }}
path: nezha
- name: Checkout Nezha Agent
uses: actions/checkout@v7
with:
repository: ${{ env.AGENT_REPO }}
ref: ${{ env.AGENT_REF }}
path: agent
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 20
cache: npm
cache-dependency-path: admin-frontend/package-lock.json
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version: "1.26.x"
cache-dependency-path: |
agent/go.sum
nezha/go.sum
- name: Install frontend dependencies
working-directory: admin-frontend
run: npm ci
- name: Install Playwright browsers
working-directory: admin-frontend
run: npx playwright install --with-deps chromium
- name: Prepare backend stubs (dist + swagger docs)
working-directory: nezha
run: |
mkdir -p cmd/dashboard/admin-dist cmd/dashboard/user-dist
printf '<!doctype html><title>admin e2e</title>\n' > cmd/dashboard/admin-dist/index.html
printf '<!doctype html><title>user e2e</title>\n' > cmd/dashboard/user-dist/index.html
go install github.com/swaggo/swag/cmd/swag@latest
"$(go env GOPATH)/bin/swag" init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
go build -o /tmp/nezha-dashboard ./cmd/dashboard
- name: Build Agent
working-directory: agent
run: go build -o /tmp/nezha-agent ./cmd/agent
- name: Start backend
working-directory: nezha
env:
GIN_MODE: release
run: |
mkdir -p data
nohup /tmp/nezha-dashboard -c data/config.yaml -db data/sqlite.db \
> /tmp/dashboard.log 2>&1 &
echo $! > /tmp/dashboard.pid
- name: Wait for backend health
run: |
for i in {1..60}; do
if curl -fsS http://127.0.0.1:8008/api/v1/setting > /dev/null; then
echo "backend is up"
exit 0
fi
sleep 1
done
echo "backend failed to start within 60s"
echo "---- dashboard.log ----"
cat /tmp/dashboard.log || true
exit 1
- name: Start Agent
working-directory: agent
env:
NZ_SERVER: 127.0.0.1:8008
NZ_CLIENT_SECRET: ${{ env.NZ_AGENTSECRETKEY }}
NZ_UUID: ${{ env.E2E_AGENT_UUID }}
NZ_TLS: "false"
NZ_DISABLE_AUTO_UPDATE: "true"
run: |
nohup /tmp/nezha-agent -c /tmp/nezha-agent-config.yml \
> /tmp/agent.log 2>&1 &
echo $! > /tmp/agent.pid
- name: Run Playwright tests
working-directory: admin-frontend
env:
CI: "true"
run: npm run e2e
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: admin-frontend/playwright-report
retention-days: 14
- name: Upload dashboard log
if: always()
uses: actions/upload-artifact@v7
with:
name: dashboard-log
path: /tmp/dashboard.log
retention-days: 7
- name: Upload Agent log
if: always()
uses: actions/upload-artifact@v7
with:
name: agent-log
path: /tmp/agent.log
retention-days: 7
- name: Stop Agent
if: always()
run: |
if [ -f /tmp/agent.pid ]; then
kill "$(cat /tmp/agent.pid)" || true
fi
- name: Stop backend
if: always()
run: |
if [ -f /tmp/dashboard.pid ]; then
kill "$(cat /tmp/dashboard.pid)" || true
fi
+37
View File
@@ -0,0 +1,37 @@
name: Frontend CI
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22.19.0
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run lint
- name: Build frontend
run: npm run build
- name: Run unit tests
run: npm run test
+3 -3
View File
@@ -12,12 +12,12 @@ jobs:
contents: write
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
with:
bun-version: "latest"
@@ -32,7 +32,7 @@ jobs:
run: zip -r dist.zip dist
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: dist.zip
generate_release_notes: true
+6
View File
@@ -24,3 +24,9 @@ dist-ssr
*.sw?
bun.lock
pnpm-lock.yaml
# Playwright
/playwright-report
/test-results
/blob-report
/playwright/.cache
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "error"
},
"env": {
"browser": true
},
"ignorePatterns": ["dist"],
"plugins": ["eslint", "typescript", "react"],
"rules": {
"no-case-declarations": "error",
"no-empty": "error",
"no-fallthrough": "error",
"no-prototype-builtins": "error",
"no-regex-spaces": "error",
"no-unexpected-multiline": "error",
"no-useless-assignment": "error",
"preserve-caught-error": "error",
"no-array-constructor": "error",
"no-var": "error",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-namespace": "error",
"typescript/no-require-imports": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-function-type": "error",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"typescript/no-explicit-any": "off",
"react/only-export-components": "off",
// React Compiler lint stays opt-in until the compiler is adopted.
"react/react-compiler": "off"
},
"overrides": [
{
"files": [
"vite.config.ts",
"vitest.config.ts",
"playwright.config.ts",
"postcss.config.js",
"tailwind.config.js"
],
"env": {
"browser": false,
"node": true
}
},
{
"files": ["src/test/**/*.{ts,tsx}"],
"env": {
"browser": true,
"node": true,
"vitest": true
}
},
{
"files": ["tests/e2e/**/*.ts"],
"env": {
"browser": false,
"node": true
}
},
{
"files": [
"src/components/server-config.tsx",
"src/components/server-config-batch.tsx",
"src/routes/server.tsx"
],
"rules": {
// Existing migration debt remains visible without blocking lint.
"no-useless-assignment": "warn"
}
},
{
"files": ["tests/e2e/fixtures.ts"],
"rules": {
// Playwright fixture callbacks are not React hooks.
"react/rules-of-hooks": "off"
}
}
]
}
+1
View File
@@ -1 +1,2 @@
src/main.tsx
.github/workflows/*
+58
View File
@@ -5,3 +5,61 @@
```bash
npx swagger-typescript-api -p http://localhost:8008/swagger/doc.json -o ./src/types -n api.ts --no-client --union-enums
```
## End-to-end tests
Playwright suite lives in `tests/e2e/` and runs in CI via
`.github/workflows/e2e.yml`. The workflow checks out the backend
(`nezhahq/nezha` master), starts it on `127.0.0.1:8008`, then runs
`npm run e2e` here.
### Run locally
First install browsers once:
```bash
npm run e2e:install
```
Then start the backend separately. The shape the CI workflow uses is:
```bash
# in a checkout of nezhahq/nezha
mkdir -p cmd/dashboard/admin-dist cmd/dashboard/user-dist
printf '<!doctype html><title>admin e2e</title>\n' > cmd/dashboard/admin-dist/index.html
printf '<!doctype html><title>user e2e</title>\n' > cmd/dashboard/user-dist/index.html
go install github.com/swaggo/swag/cmd/swag@latest
"$(go env GOPATH)/bin/swag" init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
go build -o /tmp/nezha-dashboard ./cmd/dashboard
NZ_LISTENHOST=127.0.0.1 NZ_LISTENPORT=8008 \
NZ_JWTSECRETKEY=e2e-jwt-secret-key-min-32-chars-long-ok \
NZ_AGENTSECRETKEY=e2e-agent-secret-32-bytes-long-ok \
NZ_SITENAME=nezha-e2e GIN_MODE=release \
/tmp/nezha-dashboard -c data/config.yaml -db data/sqlite.db
```
Then back in this repo:
```bash
npm run e2e # Vite dev server starts automatically
npm run e2e -- --ui # interactive runner for debugging
```
### Reusing a running stack
If you already have Vite (`npm run dev`) and the backend up:
```bash
E2E_SKIP_WEBSERVER=1 E2E_BASE_URL=http://localhost:5173 npm run e2e
```
Override credentials for a non-default admin account with `E2E_ADMIN_USER`
and `E2E_ADMIN_PASS`.
### Triaging a CI failure
The workflow uploads two artifacts on failure:
- `playwright-report` — HTML report; open `index.html` locally.
- `dashboard-log` — full backend stdout/stderr for the test run.
-27
View File
@@ -1,27 +0,0 @@
import js from "@eslint/js"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import globals from "globals"
import tseslint from "typescript-eslint"
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"@typescript-eslint/no-explicit-any": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
indent: ["error", 4],
},
},
)
+2976 -4381
View File
File diff suppressed because it is too large Load Diff
+62 -59
View File
@@ -7,78 +7,81 @@
"dev": "vite",
"build": "tsc -b && vite build",
"build-ignore-error": "vite build",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"lint": "oxlint .",
"lint:fix": "oxlint --fix .",
"format": "prettier --write .",
"preview": "vite preview"
"test": "vitest run",
"preview": "vite preview",
"e2e": "playwright test",
"e2e:install": "playwright install --with-deps chromium"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-avatar": "^1.2.6",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/postcss": "^4.1.14",
"@tanstack/react-table": "^8.21.3",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/luxon": "^3.7.1",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-navigation-menu": "^1.2.22",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@tailwindcss/postcss": "^4.3.3",
"@tanstack/react-table": "^9.1.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/luxon": "^3.7.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.22",
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.2.0",
"copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0",
"framer-motion": "^13.1.1",
"i18next": "^26.4.0",
"i18next-browser-languagedetector": "^8.2.1",
"jotai-zustand": "^0.6.0",
"lucide-react": "^0.545.0",
"lucide-react": "^1.34.0",
"luxon": "^3.7.2",
"next-themes": "^0.4.6",
"prettier-plugin-tailwindcss": "^0.6.14",
"react": "^19.2.0",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-i18next": "^16.0.0",
"react-router-dom": "^7.9.4",
"react-virtuoso": "^4.14.1",
"sonner": "^2.0.7",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"react": "^19.2.8",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.8",
"react-hook-form": "^7.86.0",
"react-i18next": "^17.0.12",
"react-router-dom": "^7.18.2",
"react-virtuoso": "^4.18.12",
"simple-icons": "^16.28.0",
"sonner": "^2.0.8",
"swr": "^2.5.1",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^4.1.12",
"zustand": "^5.0.8"
"zod": "^4.4.3",
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^9.37.0",
"@types/node": "^24.7.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.37.0",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-refresh": "^0.4.23",
"globals": "^16.4.0",
"postcss": "8.4.24",
"swagger-typescript-api": "^13.2.15",
"tailwindcss": "3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.0",
"vite": "^7.1.9"
"@playwright/test": "^1.62.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^26.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"autoprefixer": "^10.5.4",
"jsdom": "^30.0.1",
"oxlint": "1.80.0",
"postcss": "8.5.26",
"swagger-typescript-api": "^13.12.6",
"tailwindcss": "4.3.3",
"typescript": "~7.0.2",
"vite": "^8.2.2",
"vitest": "^4.1.11"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, devices } from "@playwright/test"
const baseURL = process.env.E2E_BASE_URL || "http://localhost:5173"
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list",
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: process.env.CI ? "retain-on-failure" : "off",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: process.env.E2E_SKIP_WEBSERVER
? undefined
: {
command: "npm run dev -- --host 127.0.0.1",
url: baseURL + "/dashboard/login",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+1 -2
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
},
}
+210
View File
@@ -0,0 +1,210 @@
import { FetcherMethod, fetcher } from "./api"
export interface ApiTokenView {
id: number
name: string
scopes: string[]
server_ids?: number[]
expires_at?: string
last_used_at?: string
last_used_ip?: string
created_at: string
}
export interface ApiTokenCreateRequest {
name: string
scopes: string[]
server_ids?: number[]
expires_in_days?: number
}
export interface ApiTokenCreateResponse {
id: number
name: string
token: string
scopes: string[]
server_ids?: number[]
expires_at?: string
}
export const listApiTokens = async (): Promise<ApiTokenView[]> => {
const tokens = await fetcher<ApiTokenView[]>(FetcherMethod.GET, "/api/v1/api-tokens", null)
// Go encodes an empty scope slice as JSON null; coerce so callers can map() safely.
return (tokens ?? []).map((tok) => ({
...tok,
scopes: Array.isArray(tok.scopes) ? tok.scopes : [],
}))
}
export const createApiToken = async (
data: ApiTokenCreateRequest,
): Promise<ApiTokenCreateResponse> => {
return fetcher<ApiTokenCreateResponse>(FetcherMethod.POST, "/api/v1/api-tokens", data)
}
export const deleteApiToken = async (id: number): Promise<void> => {
return fetcher<void>(FetcherMethod.DELETE, `/api/v1/api-tokens/${id}`, null)
}
export const SCOPE_OPTIONS = [
{
value: "nezha:inventory:read",
label: "Inventory: read",
desc: "List servers & groups (server.list)",
},
{
value: "nezha:inventory:delete",
label: "Inventory: delete",
desc: "Delete servers & server groups",
},
{ value: "nezha:inventory:*", label: "Inventory: all", desc: "List + delete servers & groups" },
{
value: "nezha:server:read",
label: "Server: read",
desc: "Inspect a server, read files & metrics (needs server id)",
},
{ value: "nezha:server:write", label: "Server: write", desc: "Edit a server, push files" },
{ value: "nezha:server:delete", label: "Server: delete", desc: "Delete files on a server" },
{ value: "nezha:server:exec", label: "Server: exec", desc: "Run shell commands on servers" },
{
value: "nezha:server:*",
label: "Server: all",
desc: "Every server operation (read+write+delete+exec)",
},
{
value: "nezha:service:read",
label: "Service monitor: read",
desc: "List service monitors & history",
},
{
value: "nezha:service:write",
label: "Service monitor: write",
desc: "Create / edit service monitors",
},
{
value: "nezha:service:delete",
label: "Service monitor: delete",
desc: "Delete service monitors",
},
{
value: "nezha:service:*",
label: "Service monitor: all",
desc: "Every service monitor permission",
},
{ value: "nezha:alertrule:read", label: "Alert rule: read", desc: "List alert rules" },
{
value: "nezha:alertrule:write",
label: "Alert rule: write",
desc: "Create / edit alert rules",
},
{ value: "nezha:alertrule:delete", label: "Alert rule: delete", desc: "Delete alert rules" },
{ value: "nezha:alertrule:*", label: "Alert rule: all", desc: "Every alert-rule permission" },
{ value: "nezha:cron:read", label: "Cron: read", desc: "List scheduled tasks" },
{ value: "nezha:cron:write", label: "Cron: write", desc: "Create / edit scheduled tasks" },
{ value: "nezha:cron:delete", label: "Cron: delete", desc: "Delete scheduled tasks" },
{ value: "nezha:cron:exec", label: "Cron: trigger", desc: "Manually trigger scheduled tasks" },
{ value: "nezha:cron:*", label: "Cron: all", desc: "Every cron permission" },
{ value: "nezha:notification:read", label: "Notification: read", desc: "List notifications" },
{
value: "nezha:notification:write",
label: "Notification: write",
desc: "Create / edit notifications",
},
{
value: "nezha:notification:delete",
label: "Notification: delete",
desc: "Delete notifications",
},
{
value: "nezha:notification:*",
label: "Notification: all",
desc: "Every notification permission",
},
{
value: "nezha:notification-group:read",
label: "Notification group: read",
desc: "List notification groups",
},
{
value: "nezha:notification-group:write",
label: "Notification group: write",
desc: "Create / edit groups",
},
{
value: "nezha:notification-group:delete",
label: "Notification group: delete",
desc: "Delete groups",
},
{
value: "nezha:notification-group:*",
label: "Notification group: all",
desc: "Every notification-group permission",
},
{ value: "nezha:ddns:read", label: "DDNS: read", desc: "List DDNS profiles" },
{ value: "nezha:ddns:write", label: "DDNS: write", desc: "Create / edit DDNS profiles" },
{ value: "nezha:ddns:delete", label: "DDNS: delete", desc: "Delete DDNS profiles" },
{ value: "nezha:ddns:*", label: "DDNS: all", desc: "Every DDNS permission" },
{ value: "nezha:nat:read", label: "NAT: read", desc: "List NAT rules" },
{ value: "nezha:nat:write", label: "NAT: write", desc: "Create / edit NAT rules" },
{ value: "nezha:nat:delete", label: "NAT: delete", desc: "Delete NAT rules" },
{ value: "nezha:nat:*", label: "NAT: all", desc: "Every NAT permission" },
{ value: "nezha:transfer:read", label: "Transfer: read", desc: "Read server transfer state" },
{ value: "nezha:transfer:write", label: "Transfer: write", desc: "Cancel / retry transfers" },
{
value: "nezha:transfer:delete",
label: "Transfer: delete",
desc: "Delete server transfer records",
},
{ value: "nezha:transfer:*", label: "Transfer: all", desc: "Every transfer permission" },
{
value: "nezha:admin:*",
label: "Admin: all (admin only)",
desc: "User / WAF / Setting / Online-user management",
},
{ value: "nezha:*", label: "Everything (admin only)", desc: "Full access to all resources" },
] as const
export type Scope = (typeof SCOPE_OPTIONS)[number]["value"]
export type ParseServerIDsResult =
{ ok: true; value: number[] | undefined } | { ok: false; error: string }
export type ParseExpiresInDaysResult =
{ ok: true; value: number | undefined } | { ok: false; error: string }
// Validates the "expires in days" field before it is sent to the backend.
// The backend model field is `ExpiresInDays int`, so a fractional value would
// fail JSON binding; reject it locally. Blank and 0 both mean "never expires"
// (undefined), matching the create handler's `expires_in_days == 0` semantics.
export function parseExpiresInDaysInput(raw: string): ParseExpiresInDaysResult {
const trimmed = raw.trim()
if (trimmed === "") return { ok: true, value: undefined }
if (!/^\d+$/.test(trimmed)) return { ok: false, error: `invalid expiry: ${raw}` }
const n = Number(trimmed)
if (!Number.isInteger(n) || n < 0 || n > 3650) {
return { ok: false, error: `invalid expiry: ${raw}` }
}
return { ok: true, value: n > 0 ? n : undefined }
}
export function parseServerIDsInput(raw: string): ParseServerIDsResult {
const trimmed = raw.trim()
if (trimmed === "") return { ok: true, value: undefined }
const parts = trimmed.split(",").map((s) => s.trim())
const out: number[] = []
const seen = new Set<number>()
for (const p of parts) {
if (p === "") return { ok: false, error: "empty server id" }
if (!/^\d+$/.test(p)) return { ok: false, error: `invalid server id: ${p}` }
const n = Number(p)
// Server IDs are uint64 on the backend; reject values that lose
// precision as a JS number, otherwise the PAT could bind to a
// different server than the operator typed.
if (!Number.isSafeInteger(n) || n <= 0)
return { ok: false, error: `invalid server id: ${p}` }
if (seen.has(n)) continue
seen.add(n)
out.push(n)
}
return { ok: true, value: out }
}
+67 -6
View File
@@ -23,17 +23,56 @@ export enum FetcherMethod {
let lastestRefreshTokenAt = 0
const csrfCookieName = "nz-csrf"
const csrfHeaderName = "X-CSRF-Token"
function readCookie(name: string): string {
const prefix = name + "="
for (const part of document.cookie.split(";")) {
const c = part.trim()
if (c.startsWith(prefix)) return c.slice(prefix.length)
}
return ""
}
function isUnsafeMethod(method: FetcherMethod): boolean {
return method !== FetcherMethod.GET
}
// Only attach the CSRF token to same-origin requests. Keying on HTTP method
// alone would leak the nz-csrf value to any absolute cross-origin URL a caller
// passes in; the double-submit token is meaningful only to our own backend.
function isSameOrigin(path: string): boolean {
try {
return new URL(path, window.location.origin).origin === window.location.origin
} catch {
return false
}
}
// Double-submit CSRF: backend requires X-CSRF-Token == nz-csrf cookie on
// cookie-authenticated unsafe methods.
function csrfHeaders(method: FetcherMethod, path: string): Record<string, string> {
if (!isUnsafeMethod(method)) return {}
if (!isSameOrigin(path)) return {}
const token = readCookie(csrfCookieName)
return token ? { [csrfHeaderName]: token } : {}
}
export async function fetcher<T>(method: FetcherMethod, path: string, data?: any): Promise<T> {
let response
if (method === FetcherMethod.GET || method === FetcherMethod.DELETE) {
response = await fetch(buildUrl(path, data), {
method: method,
headers: csrfHeaders(method, path),
})
} else {
response = await fetch(path, {
method: method,
headers: {
"Content-Type": "application/json",
...csrfHeaders(method, path),
},
body: data ? JSON.stringify(data) : null,
})
@@ -41,23 +80,45 @@ export async function fetcher<T>(method: FetcherMethod, path: string, data?: any
if (!response.ok) {
throw new Error(response.statusText)
}
const responseData: CommonResponse<T> = await response.json()
const text = await response.text()
if (text !== "") {
let responseData: CommonResponse<T>
try {
responseData = JSON.parse(text)
} catch {
throw new Error("invalid server response")
}
if (!responseData.success) {
throw new Error(responseData.error)
}
triggerAutoRefresh()
return responseData.data
}
triggerAutoRefresh()
return undefined as T
}
// auto refresh token
// Refresh route is POST behind the CSRF gate. Defer until an nz-csrf cookie is
// available: firing without the header would only 403 and burn the 1h throttle,
// stranding sessions that predate the cookie until the backend seeds one on a
// safe GET.
function triggerAutoRefresh() {
if (!readCookie(csrfCookieName)) return
if (
document.cookie &&
(!lastestRefreshTokenAt || Date.now() - lastestRefreshTokenAt > 1000 * 60 * 60)
) {
lastestRefreshTokenAt = Date.now()
fetch("/api/v1/refresh-token")
fetch("/api/v1/refresh-token", {
method: "POST",
headers: csrfHeaders(FetcherMethod.POST, "/api/v1/refresh-token"),
})
}
return responseData.data
}
export async function swrFetcher<T>(input: string | URL | globalThis.Request, init?: RequestInit) {
return fetcher<T>(init?.method as FetcherMethod, input.toString(), init?.body)
// SWR 默认不带 initmethod 为 undefined:必须落到 GET,否则 fetcher 会走
// 带 body 的分支并对只读请求附加 CSRF 头,把 token 暴露到 GET 请求上。
const method = (init?.method as FetcherMethod) ?? FetcherMethod.GET
return fetcher<T>(method, input.toString(), init?.body)
}
+1 -1
View File
@@ -15,5 +15,5 @@ export const deleteCron = async (id: number[]): Promise<void> => {
}
export const runCron = async (id: number): Promise<void> => {
return fetcher<void>(FetcherMethod.GET, `/api/v1/cron/${id}/manual`, null)
return fetcher<void>(FetcherMethod.POST, `/api/v1/cron/${id}/manual`, null)
}
+1 -1
View File
@@ -3,5 +3,5 @@ import { ModelCreateFMResponse } from "@/types"
import { FetcherMethod, fetcher } from "./api"
export const createFM = async (id: string): Promise<ModelCreateFMResponse> => {
return fetcher<ModelCreateFMResponse>(FetcherMethod.GET, `/api/v1/file?id=${id}`, null)
return fetcher<ModelCreateFMResponse>(FetcherMethod.POST, `/api/v1/file?id=${id}`, null)
}
+15 -2
View File
@@ -1,5 +1,6 @@
import {
ModelBatchMoveServerForm,
ModelBatchMoveServerResult,
ModelServer,
ModelServerConfigForm,
ModelServerForm,
@@ -16,8 +17,20 @@ export const deleteServer = async (id: number[]): Promise<void> => {
return fetcher<void>(FetcherMethod.POST, "/api/v1/batch-delete/server", id)
}
export const batchMoveServer = async (data: ModelBatchMoveServerForm): Promise<void> => {
return fetcher<void>(FetcherMethod.POST, "/api/v1/batch-move/server", data)
// batchMoveServer kicks off one ServerTransfer per id and returns a per-id
// result. The dashboard previously returned void from this endpoint; the new
// response shape carries the transfer ID for callers that want to subscribe
// to /ws/transfer for state changes, plus structured non-pending statuses
// (permission_denied, already_transferring, server_not_found, same_owner)
// that the UI can render without parsing prose error messages.
export const batchMoveServer = async (
data: ModelBatchMoveServerForm,
): Promise<ModelBatchMoveServerResult[]> => {
return fetcher<ModelBatchMoveServerResult[]>(
FetcherMethod.POST,
"/api/v1/batch-move/server",
data,
)
}
export const forceUpdateServer = async (id: number[]): Promise<ModelServerTaskResponse> => {
+15
View File
@@ -0,0 +1,15 @@
import { ModelServerTransfer } from "@/types"
import { FetcherMethod, fetcher } from "./api"
export const getServerTransfers = async (): Promise<ModelServerTransfer[]> => {
return fetcher<ModelServerTransfer[]>(FetcherMethod.GET, "/api/v1/transfer", null)
}
export const cancelServerTransfer = async (id: number): Promise<ModelServerTransfer> => {
return fetcher<ModelServerTransfer>(FetcherMethod.POST, `/api/v1/transfer/${id}/cancel`)
}
export const retryServerTransfer = async (id: number): Promise<ModelServerTransfer> => {
return fetcher<ModelServerTransfer>(FetcherMethod.POST, `/api/v1/transfer/${id}/retry`)
}
+33 -22
View File
@@ -36,7 +36,7 @@ import { ModelAlertRule } from "@/types"
import { triggerModes } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { KeyedMutator } from "swr"
@@ -50,13 +50,15 @@ interface AlertRuleCardProps {
mutate: KeyedMutator<ModelAlertRule[]>
}
const cycleUnitSchema = z.enum(["hour", "day", "week", "month", "year"])
const ruleSchema = z.object({
type: z.string(),
min: z.number().optional(),
max: z.number().optional(),
cycle_start: z.string().optional(),
cycle_interval: z.number().optional(),
cycle_unit: z.enum(["hour", "day", "week", "month", "year"]).optional(),
cycle_unit: cycleUnitSchema.optional(),
duration: z.number().optional(),
cover: z.number().int().min(0),
ignore: z.record(z.string(), z.boolean()).optional(),
@@ -71,7 +73,7 @@ const alertRuleFormSchema = z.object({
try {
JSON.parse(val)
return true
} catch (e) {
} catch {
return false
}
},
@@ -92,10 +94,12 @@ const alertRuleFormSchema = z.object({
export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
type AlertRuleFormData = z.infer<typeof alertRuleFormSchema>
type AlertRuleEntry = z.output<typeof ruleSchema>
type AlertRuleFormInput = z.input<typeof alertRuleFormSchema>
type AlertRuleFormData = z.output<typeof alertRuleFormSchema>
const form = useForm({
resolver: zodResolver(alertRuleFormSchema) as any,
const form = useForm<AlertRuleFormInput, unknown, AlertRuleFormData>({
resolver: zodResolver(alertRuleFormSchema),
defaultValues: data
? {
...data,
@@ -124,14 +128,16 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
// 结构化规则编辑状态:从已有数据或 rules_raw 初始化
const initialRules = (() => {
try {
if (data?.rules) return data.rules as any[]
if (data?.rules) return data.rules
const raw = form.getValues("rules_raw")
return raw ? JSON.parse(raw) : []
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
return z.array(ruleSchema).parse(parsed)
} catch {
return []
}
})()
const [rulesUI, setRulesUI] = useState<any[]>(initialRules)
const [rulesUI, setRulesUI] = useState<AlertRuleEntry[]>(initialRules)
// 同步到 rules_raw(提交仍走 JSON 字符串)
useEffect(() => {
@@ -140,17 +146,22 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
} catch {
// ignore
}
}, [rulesUI])
}, [form, rulesUI])
const rulesRaw = useWatch({ control: form.control, name: "rules_raw" })
const onSubmit = async (values: AlertRuleFormData) => {
values.rules = JSON.parse(values.rules_raw)
values.rules = z.array(ruleSchema).parse(JSON.parse(values.rules_raw))
values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number)
values.recover_trigger_tasks = conv.strToArr(values.recover_trigger_tasks_raw).map(Number)
const { rules_raw, ...requiredFields } = values
const requiredFields = { ...values }
delete (requiredFields as Record<string, unknown>).rules_raw
try {
data?.id
? await updateAlertRule(data.id, requiredFields)
: await createAlertRule(requiredFields)
if (data?.id) {
await updateAlertRule(data.id, requiredFields)
} else {
await createAlertRule(requiredFields)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -184,10 +195,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit as any)}
className="space-y-2 my-2"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
<FormField
control={form.control}
name="name"
@@ -531,7 +539,10 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
const next = [...rulesUI]
next[idx] = {
...next[idx],
cycle_unit: val,
cycle_unit:
cycleUnitSchema.parse(
val,
),
}
setRulesUI(next)
}}
@@ -603,7 +614,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
<FormControl>
<Textarea
className="resize-y"
value={form.watch("rules_raw")}
value={rulesRaw}
onChange={(e) => {
// 同步到结构化编辑器
form.setValue("rules_raw", e.target.value, {
@@ -630,7 +641,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
placeholder={t("Search")}
options={ngroupList}
onValueChange={field.onChange}
defaultValue={field.value.toString()}
defaultValue={String(field.value ?? "")}
/>
</FormControl>
<FormMessage />
+26 -2
View File
@@ -33,8 +33,9 @@ export const BatchMoveServerIcon: React.FC<BatchMoveServerIconProps> = ({
const [toUserId, setToUserId] = useState<number | undefined>(undefined)
const onSubmit = async () => {
let results
try {
await batchMoveServer({
results = await batchMoveServer({
ids: serverIds,
to_user: toUserId!,
})
@@ -45,7 +46,30 @@ export const BatchMoveServerIcon: React.FC<BatchMoveServerIconProps> = ({
})
return
}
toast(t("Done"))
// The backend now responds per-server; render a structured summary
// instead of "Done" so the operator can see which ids hit a snag
// (DisableCommandExecute / already_transferring / etc). Pending ids
// remain async — watching /ws/transfer or the Transfers page will
// surface their terminal state.
const pending = results.filter((r) => r.status === "pending").length
const denied = results.filter((r) => r.status === "permission_denied").length
const dup = results.filter((r) => r.status === "already_transferring").length
const missing = results.filter((r) => r.status === "server_not_found").length
const same = results.filter((r) => r.status === "same_owner").length
const tooOld = results.filter((r) => r.status === "agent_too_old").length
const parts: string[] = []
if (pending) parts.push(t("Transfer.PendingCount", { count: pending }))
if (denied) parts.push(t("Transfer.PermissionDeniedCount", { count: denied }))
if (dup) parts.push(t("Transfer.AlreadyTransferringCount", { count: dup }))
if (missing) parts.push(t("Transfer.ServerNotFoundCount", { count: missing }))
if (same) parts.push(t("Transfer.SameOwnerCount", { count: same }))
if (tooOld) parts.push(t("Transfer.AgentTooOldCount", { count: tooOld }))
toast(t("Transfer.BatchSubmitted"), {
description: parts.join(" · ") || t("Done"),
})
setOpen(false)
}
+13 -8
View File
@@ -63,21 +63,22 @@ const cronFormSchema = z.object({
notification_group_id: z.coerce.number().int(),
})
type CronFormData = z.infer<typeof cronFormSchema>
type CronFormInput = z.input<typeof cronFormSchema>
type CronFormData = z.output<typeof cronFormSchema>
export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
const form = useForm<CronFormData>({
resolver: zodResolver(cronFormSchema as any),
const form = useForm<CronFormInput, unknown, CronFormData>({
resolver: zodResolver(cronFormSchema),
defaultValues: data
? {
task_type: data.task_type ?? 0,
name: data.name ?? "",
scheduler: data.scheduler ?? "",
command: (data as any).command ?? "",
command: data.command ?? "",
servers: data.servers ?? [],
cover: data.cover ?? 0,
push_successful: (data as any).push_successful ?? false,
push_successful: data.push_successful ?? false,
notification_group_id: data.notification_group_id ?? 0,
}
: {
@@ -99,7 +100,11 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
const onSubmit = async (values: CronFormData) => {
try {
data?.id ? await updateCron(data.id, values) : await createCron(values)
if (data?.id) {
await updateCron(data.id, values)
} else {
await createCron(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -267,7 +272,7 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
placeholder="Search..."
options={ngroupList}
onValueChange={field.onChange}
defaultValue={field.value.toString()}
defaultValue={String(field.value ?? "")}
/>
</FormControl>
<FormMessage />
@@ -282,7 +287,7 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
+31 -18
View File
@@ -67,28 +67,29 @@ const ddnsFormSchema = z.object({
webhook_headers: asOptionalField(z.string()),
})
type DDNSFormData = z.infer<typeof ddnsFormSchema>
type DDNSFormInput = z.input<typeof ddnsFormSchema>
type DDNSFormData = z.output<typeof ddnsFormSchema>
export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) => {
const { t } = useTranslation()
const form = useForm<DDNSFormData>({
resolver: zodResolver(ddnsFormSchema as any),
const form = useForm<DDNSFormInput, unknown, DDNSFormData>({
resolver: zodResolver(ddnsFormSchema),
defaultValues: data
? {
max_retries: data.max_retries ?? 3,
enable_ipv4: (data as any).enable_ipv4 ?? false,
enable_ipv6: (data as any).enable_ipv6 ?? false,
enable_ipv4: data.enable_ipv4 ?? false,
enable_ipv6: data.enable_ipv6 ?? false,
name: data.name ?? "",
provider: data.provider ?? "dummy",
domains: data.domains ?? [],
domains_raw: conv.arrToStr(data.domains ?? []),
access_id: (data as any).access_id ?? "",
access_secret: (data as any).access_secret ?? "",
webhook_url: (data as any).webhook_url ?? "",
webhook_method: (data as any).webhook_method,
webhook_request_type: (data as any).webhook_request_type,
webhook_request_body: (data as any).webhook_request_body ?? "",
webhook_headers: (data as any).webhook_headers ?? "",
access_id: data.access_id ?? "",
access_secret: data.access_secret ?? "",
webhook_url: data.webhook_url ?? "",
webhook_method: data.webhook_method,
webhook_request_type: data.webhook_request_type,
webhook_request_body: data.webhook_request_body ?? "",
webhook_headers: data.webhook_headers ?? "",
}
: {
max_retries: 3,
@@ -116,7 +117,11 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
const onSubmit = async (values: DDNSFormData) => {
try {
values.domains = conv.strToArr(values.domains_raw)
data?.id ? await updateDDNSProfile(data.id, values) : await createDDNSProfile(values)
if (data?.id) {
await updateDDNSProfile(data.id, values)
} else {
await createDDNSProfile(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -227,15 +232,23 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
render={({ field }) => {
const { value, ...fieldProps } = field
return (
<FormItem>
<FormLabel>{t("MaximumRetryAttempts")}</FormLabel>
<FormControl>
<Input type="number" placeholder="3" {...field} />
<Input
type="number"
placeholder="3"
value={String(value ?? "")}
{...fieldProps}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
)
}}
/>
<FormField
control={form.control}
@@ -351,7 +364,7 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
@@ -371,7 +384,7 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
+106 -88
View File
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { useMediaQuery } from "@/hooks/useMediaQuery"
import { virtualizedTableFeatures } from "@/lib/table"
import { copyToClipboard, fm, formatPath, fmWorker as worker } from "@/lib/utils"
import {
FMEntry,
@@ -37,7 +38,7 @@ import {
import { ColumnDef } from "@tanstack/react-table"
import { Row, flexRender } from "@tanstack/react-table"
import { File, Folder } from "lucide-react"
import { HTMLAttributes, JSX, useEffect, useRef, useState } from "react"
import { HTMLAttributes, JSX, useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -59,6 +60,10 @@ interface FMProps {
wsUrl: string
}
type VirtualizedTableRowProps = HTMLAttributes<HTMLTableRowElement> & {
"data-index"?: number | string
}
const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
@@ -67,21 +72,20 @@ const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
return true
}
const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl, ...props }) => {
export const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({
wsUrl,
...props
}) => {
const { t } = useTranslation()
const fmRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
return () => {
wsRef.current?.close()
}
}, [])
const tRef = useRef(t)
tRef.current = t
const [dOpen, setdOpen] = useState(false)
const [uOpen, setuOpen] = useState(false)
const columns: ColumnDef<FMEntry>[] = [
const columns: ColumnDef<typeof virtualizedTableFeatures, FMEntry>[] = [
{
id: "type",
header: () => <span>{t("Type")}</span>,
@@ -119,10 +123,9 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
},
]
const tableRowComponent = (rows: Row<FMEntry>[]) =>
function getTableRow(props: HTMLAttributes<HTMLTableRowElement>) {
// @ts-expect-error data-index is a valid attribute
const index = props["data-index"]
const tableRowComponent = (rows: Row<typeof virtualizedTableFeatures, FMEntry>[]) =>
function getTableRow(props: VirtualizedTableRowProps) {
const index = Number(props["data-index"])
const row = rows[index]
if (!row) return null
@@ -160,71 +163,6 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
}
}
useEffect(() => {
const url = new URL(wsUrl, window.location.origin)
url.protocol = url.protocol.replace("http", "ws")
const ws = new WebSocket(url)
wsRef.current = ws
ws.binaryType = "arraybuffer"
ws.onopen = () => {
listFile()
}
ws.onclose = (e) => {
console.log("WebSocket connection closed:", e)
}
ws.onerror = (e) => {
console.error(e)
toast("Websocket" + " " + t("Error"), {
description: t("Results.UnExpectedError"),
})
}
ws.onmessage = async (e: MessageEvent<ArrayBufferLike>) => {
try {
const identifier = new Uint8Array(e.data, 0, 4)
if (arraysEqual(identifier, FMIdentifier.error)) {
const errBytes = e.data.slice(4)
const errMsg = new TextDecoder("utf-8").decode(errBytes)
throw new Error(errMsg)
}
if (firstChunk.current) {
if (arraysEqual(identifier, FMIdentifier.file)) {
worker.postMessage({
operation: 1,
arrayBuffer: e.data,
fileName: currentBasename.current,
})
firstChunk.current = false
} else if (arraysEqual(identifier, FMIdentifier.fileName)) {
const { path, fmList } = await fm.parseFMList(e.data)
setPath(path)
setFMEntries(fmList)
} else if (arraysEqual(identifier, FMIdentifier.complete)) {
// Upload completed
setuOpen(false)
listFile()
} else {
throw new Error(t("Results.UnknownIdentifier"))
}
} else {
await waitForHandleReady()
worker.postMessage({
operation: 2,
arrayBuffer: e.data,
fileName: currentBasename.current,
})
}
} catch (error) {
console.error("Error processing received data:", error)
toast("FM" + " " + t("Error"), {
description: t("Results.UnExpectedError"),
})
setdOpen(false)
setuOpen(false)
}
}
}, [wsUrl])
useEffect(() => {
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
switch (event.data.type) {
@@ -265,25 +203,101 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload)
}
}, [worker, dOpen])
}, [dOpen])
const [currentPath, setPath] = useState("")
useEffect(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
listFile()
}
}, [wsRef.current, currentPath])
const currentPathRef = useRef(currentPath)
currentPathRef.current = currentPath
const listFile = () => {
const listFile = useCallback(() => {
const prefix = new Int8Array([FMOpcode.List])
const pathMsg = new TextEncoder().encode(currentPath)
const pathMsg = new TextEncoder().encode(currentPathRef.current)
const msg = new Int8Array(prefix.length + pathMsg.length)
msg.set(prefix)
msg.set(pathMsg, prefix.length)
wsRef.current?.send(msg)
}, [])
// The WebSocket initialization must not depend on listFile or currentPath,
// otherwise navigating directories triggers a disconnect and reconnect.
useEffect(() => {
const url = new URL(wsUrl, window.location.origin)
url.protocol = url.protocol.replace("http", "ws")
const ws = new WebSocket(url)
wsRef.current = ws
ws.binaryType = "arraybuffer"
ws.onopen = () => {
listFile()
}
ws.onclose = (e) => {
console.log("WebSocket connection closed:", e)
}
ws.onerror = (e) => {
console.error(e)
toast("Websocket" + " " + tRef.current("Error"), {
description: tRef.current("Results.UnExpectedError"),
})
}
ws.onmessage = async (e: MessageEvent<ArrayBufferLike>) => {
try {
const identifier = new Uint8Array(e.data, 0, 4)
if (arraysEqual(identifier, FMIdentifier.error)) {
const errBytes = e.data.slice(4)
const errMsg = new TextDecoder("utf-8").decode(errBytes)
throw new Error(errMsg)
}
if (firstChunk.current) {
if (arraysEqual(identifier, FMIdentifier.file)) {
worker.postMessage({
operation: 1,
arrayBuffer: e.data,
fileName: currentBasename.current,
})
firstChunk.current = false
} else if (arraysEqual(identifier, FMIdentifier.fileName)) {
const { path, fmList } = await fm.parseFMList(e.data)
setPath(path)
setFMEntries(fmList)
} else if (arraysEqual(identifier, FMIdentifier.complete)) {
// Upload completed
setuOpen(false)
listFile()
} else {
throw new Error(tRef.current("Results.UnknownIdentifier"))
}
} else {
await waitForHandleReady()
worker.postMessage({
operation: 2,
arrayBuffer: e.data,
fileName: currentBasename.current,
})
}
} catch (error) {
console.error("Error processing received data:", error)
toast("FM" + " " + tRef.current("Error"), {
description: tRef.current("Results.UnExpectedError"),
})
setdOpen(false)
setuOpen(false)
}
}
return () => {
ws.close()
if (wsRef.current === ws) {
wsRef.current = null
}
}
}, [listFile, wsUrl])
useEffect(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
listFile()
}
}, [currentPath, listFile])
const downloadFile = (basename: string) => {
currentBasename.current = basename
@@ -331,9 +345,13 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
onClick={async () => {
try {
await copyToClipboard(formatPath(currentPath))
} catch (error: any) {
} catch (error) {
const description =
error instanceof Error
? error.message
: t("Results.UnExpectedError")
toast("FM" + " " + t("Error"), {
description: error.message,
description,
})
console.error("copy error: ", error)
}
+16 -2
View File
@@ -51,6 +51,7 @@ const pages = [
{ href: "/dashboard/nat", label: i18next.t("NATT") },
{ href: "/dashboard/domain", label: i18next.t("Domain") }, // <-- 新增的域名监控链接
{ href: "/dashboard/server-group", label: i18next.t("Group") },
{ href: "/dashboard/transfer", label: i18next.t("Transfer.Title") },
]
// ^^^^^^^^^^^ 1. 在这里为移动端菜单添加新页面 ^^^^^^^^^^^
// =======================================================
@@ -59,6 +60,7 @@ export default function Header() {
const { t } = useTranslation()
const { logout } = useAuth()
const profile = useMainStore((store) => store.profile)
const isAdmin = profile?.role === 0
const location = useLocation()
const isDesktop = useMediaQuery("(min-width: 890px)")
@@ -146,6 +148,7 @@ export default function Header() {
{t("Profile")}
</div>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false)
@@ -158,6 +161,7 @@ export default function Header() {
{t("Settings")}
</div>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -264,6 +268,15 @@ export default function Header() {
<Link to="/dashboard/server-group">{t("Group")}</Link>
</NzNavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NzNavigationMenuLink
asChild
active={location.pathname === "/dashboard/transfer"}
className={navigationMenuTriggerStyle()}
>
<Link to="/dashboard/transfer">{t("Transfer.Title")}</Link>
</NzNavigationMenuLink>
</NavigationMenuItem>
</>
)}
</div>
@@ -353,6 +366,7 @@ export default function Header() {
{t("Profile")}
</div>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false)
@@ -365,6 +379,7 @@ export default function Header() {
{t("Settings")}
</div>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="cursor-pointer">
@@ -399,8 +414,7 @@ const useInterval = (callback: () => void, delay?: number | null) => {
function Overview() {
const { t } = useTranslation()
const profile = useMainStore((store) => store.profile)
const timeOption = DateTime.TIME_SIMPLE
timeOption.hour12 = true
const timeOption = { ...DateTime.TIME_SIMPLE, hour12: true }
const [timeString, setTimeString] = useState(
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
)
+5 -4
View File
@@ -147,11 +147,11 @@ const generateCommand = (
const env = envParts.join(" ")
const envWinParts = [
`$env:NZ_SERVER=\"${install_host}\";`,
`$env:NZ_TLS=\"${tls || false}\";`,
`$env:NZ_CLIENT_SECRET=\"${agent_secret}\";`,
`$env:NZ_SERVER="${install_host}";`,
`$env:NZ_TLS="${tls || false}";`,
`$env:NZ_CLIENT_SECRET="${agent_secret}";`,
]
if (uuid) envWinParts.push(`$env:NZ_UUID=\"${uuid}\";`)
if (uuid) envWinParts.push(`$env:NZ_UUID="${uuid}";`)
const env_win = envWinParts.join("")
switch (type) {
@@ -161,6 +161,7 @@ const generateCommand = (
}
case OSTypes.Windows: {
return `${env_win} [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Ssl3 -bor [Net.SecurityProtocolType]::Tls -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls12;set-ExecutionPolicy RemoteSigned;Invoke-WebRequest ${window.location.origin}/script/agent.ps1 -OutFile C:\\install.ps1;powershell.exe C:\\install.ps1`
}
default: {
throw new Error(`Unknown OS: ${type}`)
+25 -11
View File
@@ -46,16 +46,17 @@ const natFormSchema = z.object({
domain: z.string(),
})
type NatFormData = z.infer<typeof natFormSchema>
type NatFormInput = z.input<typeof natFormSchema>
type NatFormData = z.output<typeof natFormSchema>
export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
const form = useForm<NatFormData>({
resolver: zodResolver(natFormSchema as any),
const form = useForm<NatFormInput, unknown, NatFormData>({
resolver: zodResolver(natFormSchema),
defaultValues: data
? {
name: data.name ?? "",
enabled: (data as any).enabled ?? false,
enabled: data.enabled ?? false,
server_id: data.server_id ?? 0,
host: data.host ?? "",
domain: data.domain ?? "",
@@ -76,11 +77,16 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
const onSubmit = async (values: NatFormData) => {
try {
data?.id ? await updateNAT(data.id, values) : await createNAT(values)
} catch (e) {
if (data?.id) {
await updateNAT(data.id, values)
} else {
await createNAT(values)
}
} catch (e: unknown) {
console.error(e)
toast(t("Error"), {
description: t("Results.UnExpectedError"),
description:
e instanceof Error && e.message ? e.message : t("Results.UnExpectedError"),
})
return
}
@@ -119,15 +125,23 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
<FormField
control={form.control}
name="server_id"
render={({ field }) => (
render={({ field }) => {
const { value, ...fieldProps } = field
return (
<FormItem>
<FormLabel>{t("Server")} ID</FormLabel>
<FormControl>
<Input type="number" placeholder="1" {...field} />
<Input
type="number"
placeholder="1"
value={String(value ?? "")}
{...fieldProps}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
)
}}
/>
<FormField
control={form.control}
@@ -169,7 +183,7 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">{t("Enable")}</Label>
+5 -3
View File
@@ -64,9 +64,11 @@ export const NotificationGroupCard: React.FC<NotificationGroupCardProps> = ({ da
const onSubmit = async (values: z.infer<typeof notificationGroupFormSchema>) => {
try {
data?.group.id
? await updateNotificationGroup(data.group.id, values)
: await createNotificationGroup(values)
if (data?.group.id) {
await updateNotificationGroup(data.group.id, values)
} else {
await createNotificationGroup(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
+19 -11
View File
@@ -62,10 +62,14 @@ const notificationFormSchema = z.object({
export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
type notificationFormData = z.infer<typeof notificationFormSchema>
type NotificationFormInput = z.input<typeof notificationFormSchema>
type NotificationFormData = z.output<typeof notificationFormSchema>
type NotificationDefaults = ModelNotification &
Partial<Pick<NotificationFormData, "skip_check">>
const notificationDefaults: NotificationDefaults | undefined = data
const form = useForm({
resolver: zodResolver(notificationFormSchema) as any,
const form = useForm<NotificationFormInput, unknown, NotificationFormData>({
resolver: zodResolver(notificationFormSchema),
defaultValues: data
? {
name: data.name ?? "",
@@ -75,10 +79,12 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
request_header: data.request_header ?? "",
request_body: data.request_body ?? "",
verify_tls: data.verify_tls ?? false,
skip_check: data.skip_check ?? false,
skip_check: notificationDefaults?.skip_check ?? false,
format_metric_units: data.format_metric_units ?? false,
type: data.type ?? 1,
type: (data as any)?.type ?? 1,
}
: {
name: "",
url: "",
@@ -98,9 +104,13 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
const [open, setOpen] = useState(false)
const onSubmit = async (values: notificationFormData) => {
const onSubmit = async (values: NotificationFormData) => {
try {
data?.id ? await updateNotification(data.id, values) : await createNotification(values)
if (data?.id) {
await updateNotification(data.id, values)
} else {
await createNotification(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -128,10 +138,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit as any)}
className="space-y-2 my-2"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
<FormField
control={form.control}
name="name"
@@ -343,6 +350,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
/>
</div>
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" className="my-2" variant="secondary">
+3 -3
View File
@@ -49,7 +49,7 @@ const agentConfigSchema = z.object({
try {
JSON.parse(val)
return true
} catch (e) {
} catch {
return false
}
},
@@ -66,7 +66,7 @@ const agentConfigSchema = z.object({
try {
JSON.parse(val)
return true
} catch (e) {
} catch {
return false
}
},
@@ -129,7 +129,7 @@ export const ServerConfigCard = ({ sid, menuItem = false, ...props }: ServerConf
}
}
if (open) fetchData()
}, [open])
}, [open, sid, t])
const form = useForm({
resolver: zodResolver(agentConfigSchema) as any,
+5 -3
View File
@@ -64,9 +64,11 @@ export const ServerGroupCard: React.FC<ServerGroupCardProps> = ({ data, mutate }
const onSubmit = async (values: z.infer<typeof serverGroupFormSchema>) => {
try {
data?.group.id
? await updateServerGroup(data.group.id, values)
: await createServerGroup(values)
if (data?.group.id) {
await updateServerGroup(data.group.id, values)
} else {
await createServerGroup(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
+6 -3
View File
@@ -28,7 +28,8 @@ import { asOptionalField } from "@/lib/utils"
import { ModelServer } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { KeyedMutator } from "swr"
@@ -55,7 +56,7 @@ const serverFormSchema = z.object({
try {
JSON.parse(val)
return true
} catch (e) {
} catch {
return false
}
},
@@ -88,6 +89,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
keepDefaultValues: false,
},
})
const enableDDNS = useWatch({ control: form.control, name: "enable_ddns" })
const [open, setOpen] = useState(false)
@@ -189,7 +191,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</FormItem>
)}
/>
{form.watch("enable_ddns") ? (
{enableDDNS ? (
<>
<FormField
control={form.control as any}
@@ -331,6 +333,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</FormItem>
)}
/>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" className="my-2" variant="secondary">
+10 -7
View File
@@ -56,8 +56,8 @@ const serviceFormSchema = z.object({
cover: z.coerce.number().int().min(0),
display_index: z.coerce.number().int(),
duration: z.coerce.number().int().min(30),
enable_show_in_service: asOptionalField(z.boolean()),
enable_trigger_task: asOptionalField(z.boolean()),
hide_for_guest: asOptionalField(z.boolean()),
fail_trigger_tasks: z.array(z.number()),
fail_trigger_tasks_raw: z.string(),
latency_notify: asOptionalField(z.boolean()),
@@ -113,11 +113,14 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
values.skip_servers = conv.arrToRecord(values.skip_servers_raw)
values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number)
values.recover_trigger_tasks = conv.strToArr(values.recover_trigger_tasks_raw).map(Number)
const { skip_servers_raw, ...requiredFields } = values
const requiredFields = { ...values }
delete (requiredFields as Record<string, unknown>).skip_servers_raw
try {
data?.id
? await updateService(data.id, requiredFields)
: await createService(requiredFields)
if (data?.id) {
await updateService(data.id, requiredFields)
} else {
await createService(requiredFields)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -232,7 +235,7 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
/>
<FormField
control={form.control}
name="enable_show_in_service"
name="hide_for_guest"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
@@ -242,7 +245,7 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
{t("ShowInService")}
{t("HideForGuest")}
</Label>
</div>
</FormControl>
+7 -3
View File
@@ -8,10 +8,11 @@ export const SettingsTab = ({ className }: { className?: string }) => {
const { profile } = useAuth()
const isAdmin = profile?.role === 0
const colsClass = isAdmin ? "grid-cols-5" : "grid-cols-1"
return (
<Tabs defaultValue={window.location.pathname} className={className}>
<TabsList className="grid w-full grid-cols-4">
<TabsList className={`grid w-full ${colsClass}`}>
{isAdmin && (
<>
<TabsTrigger value="/dashboard/settings" asChild>
@@ -20,14 +21,17 @@ export const SettingsTab = ({ className }: { className?: string }) => {
<TabsTrigger value="/dashboard/settings/user" asChild>
<Link to="/dashboard/settings/user">{t("User")}</Link>
</TabsTrigger>
</>
)}
<TabsTrigger value="/dashboard/settings/online-user" asChild>
<Link to="/dashboard/settings/online-user">{t("OnlineUser")}</Link>
</TabsTrigger>
<TabsTrigger value="/dashboard/settings/waf" asChild>
<Link to="/dashboard/settings/waf">{t("WAF")}</Link>
</TabsTrigger>
</>
)}
<TabsTrigger value="/dashboard/settings/api-tokens" asChild>
<Link to="/dashboard/settings/api-tokens">{t("ApiTokens")}</Link>
</TabsTrigger>
</TabsList>
</Tabs>
)
+1 -1
View File
@@ -70,7 +70,7 @@ function Calendar({
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
+113 -3
View File
@@ -1,7 +1,117 @@
export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) {
import { useEffect, useState } from "react"
type SVGProps = React.ComponentPropsWithoutRef<"svg">
type IconMarkupState = { status: "ready"; icon: ParsedIconMarkup } | { status: "missing" }
type ParsedIconMarkup = {
title?: string
viewBox: string
path: string
}
const simpleIconModules = import.meta.glob("/node_modules/simple-icons/icons/*.svg", {
query: "?raw",
import: "default",
})
const iconLoadersBySlug = Object.fromEntries(
Object.entries(simpleIconModules).map(([path, loader]) => [
path.slice(path.lastIndexOf("/") + 1, -".svg".length),
loader as () => Promise<string>,
]),
)
const iconMarkupCache = new Map<string, IconMarkupState>()
function toProviderSlug(provider: string) {
return provider
.trim()
.replace(/[^a-z0-9]+/gi, "")
.toLowerCase()
}
function loadProviderIconMarkup(provider: string) {
const loader = iconLoadersBySlug[toProviderSlug(provider)]
return loader ? loader() : null
}
function parseIconMarkup(markup: string): ParsedIconMarkup | null {
const viewBox = markup.match(/viewBox="([^"]+)"/i)?.[1] ?? "0 0 24 24"
const title = markup.match(/<title>([^<]*)<\/title>/i)?.[1]
const path = markup.match(/<path d="([^"]+)"/i)?.[1]
if (!path) {
return null
}
return { title, viewBox, path }
}
async function loadProviderIcon(provider: string): Promise<IconMarkupState> {
const markup = await loadProviderIconMarkup(provider)
if (!markup) {
return { status: "missing" }
}
const parsed = parseIconMarkup(markup)
if (!parsed) {
return { status: "missing" }
}
return { status: "ready", icon: parsed }
}
export function OAuthProviderIcon({
provider,
title,
...props
}: SVGProps & { provider: string; title?: string }) {
const providerSlug = toProviderSlug(provider)
const [iconState, setIconState] = useState<IconMarkupState | null>(() => {
return iconMarkupCache.get(providerSlug) ?? null
})
useEffect(() => {
if (iconMarkupCache.has(providerSlug)) {
setIconState(iconMarkupCache.get(providerSlug) ?? null)
return
}
let cancelled = false
loadProviderIcon(provider)
.then((result) => {
if (cancelled) return
iconMarkupCache.set(providerSlug, result)
setIconState(result)
})
.catch(() => {
if (cancelled) return
const missingState = { status: "missing" } as const
iconMarkupCache.set(providerSlug, missingState)
setIconState(missingState)
})
return () => {
cancelled = true
}
}, [provider, providerSlug])
if (!iconState || iconState.status !== "ready") {
return null
}
const iconTitle = title ?? iconState.icon.title
return (
<svg viewBox="0 0 496 512" fill="currentColor" {...props}>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
<svg
viewBox={iconState.icon.viewBox}
fill="currentColor"
aria-hidden={iconTitle ? undefined : "true"}
role={iconTitle ? "img" : undefined}
{...props}
>
{iconTitle ? <title>{iconTitle}</title> : null}
<path d={iconState.icon.path} />
</svg>
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import { InputHTMLAttributes, forwardRef } from "react"
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = InputHTMLAttributes<HTMLInputElement>
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
-1
View File
@@ -141,7 +141,6 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
animation = 0,
maxCount = 3,
modalPopover = false,
asChild = false,
className,
...props
},
+13 -12
View File
@@ -3,16 +3,16 @@
import { ScrollArea } from "@/components/ui/scroll-area"
import { TableCell, TableHead, TableRow } from "@/components/ui/table"
import { useMediaQuery } from "@/hooks/useMediaQuery"
import { virtualizedTableFeatures } from "@/lib/table"
import { cn } from "@/lib/utils"
import {
ColumnDef,
Row,
RowData,
SortDirection,
SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
useTable,
} from "@tanstack/react-table"
import { HTMLAttributes, JSX, forwardRef, useEffect, useRef, useState } from "react"
import { TableVirtuoso } from "react-virtuoso"
@@ -26,7 +26,9 @@ const TableComponent = forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTab
)
TableComponent.displayName = "TableComponent"
const TableRowComponent = <TData,>(rows: Row<TData>[]) =>
const TableRowComponent = <TData extends RowData>(
rows: Row<typeof virtualizedTableFeatures, TData>[],
) =>
function getTableRow(props: HTMLAttributes<HTMLTableRowElement>) {
// @ts-expect-error data-index is a valid attribute
const index = props["data-index"]
@@ -64,34 +66,33 @@ function SortingIndicator({ isSorted }: { isSorted: SortDirection | false }) {
)
}
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
interface DataTableProps<TData extends RowData> {
columns: ColumnDef<typeof virtualizedTableFeatures, TData>[]
data: TData[]
rowComponent?: (
rows: Row<TData>[],
rows: Row<typeof virtualizedTableFeatures, TData>[],
) => (props: HTMLAttributes<HTMLTableRowElement>) => JSX.Element | null
}
export function DataTable<TData, TValue>({
export function DataTable<TData extends RowData>({
columns,
data,
rowComponent,
}: DataTableProps<TData, TValue>) {
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([
{
id: "type",
desc: true,
},
])
const table = useReactTable({
const table = useTable({
features: virtualizedTableFeatures,
data,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const { rows } = table.getRowModel()
+46 -13
View File
@@ -1,6 +1,6 @@
import { getProfile, login as loginRequest } from "@/api/user"
import { AuthContextProps } from "@/types"
import { createContext, useContext, useEffect, useMemo } from "react"
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
@@ -9,35 +9,57 @@ import { useMainStore } from "./useMainStore"
const AuthContext = createContext<AuthContextProps>({
profile: undefined,
loading: true,
login: () => {},
loginOauth2: () => {},
logout: () => {},
})
// Admin is role 0 on the backend. A missing/non-numeric role must never
// collapse to 0, or a malformed profile response would be treated as admin
// client-side; default unknown roles to a non-admin value instead.
const NON_ADMIN_ROLE = 1
function normalizeRole(role: unknown): number {
return typeof role === "number" && Number.isFinite(role) ? role : NON_ADMIN_ROLE
}
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const profile = useMainStore((store) => store.profile)
const setProfile = useMainStore((store) => store.setProfile)
const [loading, setLoading] = useState(true)
const { t } = useTranslation()
// An explicit login/logout (or its getProfile) resolving while the initial
// mount probe is still in flight must win: bump this so the stale probe's
// result is discarded instead of clobbering the authenticated state.
const authEpoch = useRef(0)
useEffect(() => {
const epoch = authEpoch.current
;(async () => {
try {
const user = await getProfile()
user.role = user.role || 0
if (authEpoch.current !== epoch) return
user.role = normalizeRole(user.role)
setProfile(user)
} catch (error: any) {
} catch {
if (authEpoch.current !== epoch) return
setProfile(undefined)
} finally {
setLoading(false)
}
})()
}, [])
}, [setProfile])
const navigate = useNavigate()
const login = async (username: string, password: string) => {
const login = useCallback(
async (username: string, password: string) => {
try {
await loginRequest(username, password)
const user = await getProfile()
user.role = user.role || 0
authEpoch.current++
user.role = normalizeRole(user.role)
setProfile(user)
navigate("/dashboard")
} catch (error: any) {
@@ -47,40 +69,51 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
} else {
toast(msg || t("NetworkError"))
}
} finally {
// An explicit login resolves auth regardless of the still-pending
// mount probe; clear loading so ProtectedRoute stops blanking.
setLoading(false)
}
}
},
[navigate, setProfile, t],
)
const loginOauth2 = async () => {
const loginOauth2 = useCallback(async () => {
try {
const user = await getProfile()
user.role = user.role || 0
authEpoch.current++
user.role = normalizeRole(user.role)
setProfile(user)
navigate("/dashboard")
} catch (error: any) {
toast(error.message)
} finally {
setLoading(false)
window.history.replaceState({}, document.title, window.location.pathname)
}
}
}, [navigate, setProfile])
const logout = () => {
const logout = useCallback(() => {
authEpoch.current++
document.cookie.split(";").forEach(function (c) {
document.cookie = c
.replace(/^ +/, "")
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/")
})
setProfile(undefined)
setLoading(false)
navigate("/dashboard/login", { replace: true })
}
}, [navigate, setProfile])
const value = useMemo(
() => ({
profile,
loading,
login,
loginOauth2,
logout,
}),
[profile],
[profile, loading, login, loginOauth2, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
+10 -13
View File
@@ -1,19 +1,16 @@
import { useEffect, useState } from "react"
import { useCallback, useSyncExternalStore } from "react"
export function useMediaQuery(query: string) {
const [value, setValue] = useState(false)
useEffect(() => {
function onChange(event: MediaQueryListEvent) {
setValue(event.matches)
}
const subscribe = useCallback(
(callback: () => void) => {
const result = matchMedia(query)
result.addEventListener("change", onChange)
setValue(result.matches)
result.addEventListener("change", callback)
return () => result.removeEventListener("change", callback)
},
[query],
)
return () => result.removeEventListener("change", onChange)
}, [query])
const getSnapshot = useCallback(() => matchMedia(query).matches, [query])
return value
return useSyncExternalStore(subscribe, getSnapshot, () => false)
}
+1 -1
View File
@@ -54,7 +54,7 @@ export const NotificationProvider: React.FC<NotificationProviderProps> = ({
setNotifier(undefined)
}
})()
}, [location.pathname])
}, [location.pathname, setNotifier, setNotifierGroup, withNotifier, withNotifierGroup])
const value: NotificationContextProps = useMemo(
() => ({
+1 -1
View File
@@ -54,7 +54,7 @@ export const ServerProvider: React.FC<ServerProviderProps> = ({
setServer(undefined)
}
})()
}, [location.pathname])
}, [location.pathname, setServer, setServerGroup, withServer, withServerGroup])
const value: ServerContextProps = useMemo(
() => ({
+155 -6
View File
@@ -1,8 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--text-xsm: 0.825rem;
--text-xsm--line-height: 1.25rem;
}
@layer base {
:root {
--radius: 0.5rem;
--background: 0 0% 100%;
@@ -57,7 +91,6 @@
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@@ -84,5 +117,121 @@ body,
}
::-webkit-scrollbar-thumb {
@apply rounded-full border-[1px] border-solid border-transparent bg-border bg-clip-padding;
@apply bg-border rounded-full border-[1px] border-solid border-transparent bg-clip-padding;
}
.terminal-shell {
--terminal-available-height: 70vh;
display: flex;
height: min(70vh, var(--terminal-available-height));
min-height: 360px;
flex-direction: column;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background: #09090b;
}
.terminal-screen {
min-height: 0;
flex: 1 1 auto;
padding: 6px 3px 2px 6px;
touch-action: pan-y;
}
.terminal-screen .xterm {
height: 100%;
}
.terminal-screen .xterm-viewport {
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.terminal-keyboard {
display: flex;
flex: 0 0 auto;
gap: 4px;
overflow-x: auto;
padding: 6px 6px calc(6px + env(safe-area-inset-bottom));
border-top: 1px solid hsl(var(--border));
background: hsl(var(--background));
touch-action: pan-x;
-webkit-overflow-scrolling: touch;
}
.terminal-key {
display: inline-flex;
min-width: 46px;
height: 42px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) - 2px);
background: hsl(var(--secondary));
padding: 0 9px;
color: hsl(var(--secondary-foreground));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
font-weight: 500;
user-select: none;
-webkit-user-select: none;
}
.terminal-key:hover:not(:disabled),
.terminal-key:focus-visible,
.terminal-key.is-active {
border-color: hsl(var(--ring));
background: hsl(var(--accent));
outline: none;
}
.terminal-key:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.terminal-key-arrow {
min-width: 40px;
padding-inline: 5px;
}
.terminal-key-paste {
min-width: 58px;
}
.terminal-shell:fullscreen {
width: 100%;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
}
@media (max-width: 640px) {
.terminal-shell {
height: var(--terminal-available-height);
min-height: 220px;
}
.terminal-keyboard {
gap: 2px;
padding-inline: 4px;
}
.terminal-key {
min-width: 43px;
height: 44px;
padding-inline: 5px;
}
.terminal-key-arrow {
min-width: 36px;
padding-inline: 2px;
}
.terminal-key-paste {
min-width: 49px;
}
}
+16
View File
@@ -5,10 +5,14 @@ import deTranslation from "../locales/de/translation.json"
import enTranslation from "../locales/en/translation.json"
import esTranslation from "../locales/es/translation.json"
import frTranslation from "../locales/fr/translation.json"
import glTranslation from "../locales/gl/translation.json"
import idTranslation from "../locales/id/translation.json"
import itTranslation from "../locales/it/translation.json"
import jaTranslation from "../locales/ja/translation.json"
import roTranslation from "../locales/ro/translation.json"
import ruTranslation from "../locales/ru/translation.json"
import taTranslation from "../locales/ta/translation.json"
import ukTranslation from "../locales/uk/translation.json"
import zhCNTranslation from "../locales/zh-CN/translation.json"
import zhTWTranslation from "../locales/zh-TW/translation.json"
@@ -43,6 +47,18 @@ const resources = {
"id-ID": {
translation: idTranslation,
},
"ja-JP": {
translation: jaTranslation,
},
"ro-RO": {
translation: roTranslation,
},
"uk-UA": {
translation: ukTranslation,
},
"gl-ES": {
translation: glTranslation,
},
}
const getStoredLanguage = () => {
+29
View File
@@ -0,0 +1,29 @@
import {
columnSizingFeature,
columnVisibilityFeature,
createSortedRowModel,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_datetime,
sortFn_text,
tableFeatures,
} from "@tanstack/react-table"
export const selectableTableFeatures = tableFeatures({
columnVisibilityFeature,
rowSelectionFeature,
})
export const virtualizedTableFeatures = tableFeatures({
columnSizingFeature,
columnVisibilityFeature,
rowSelectionFeature,
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: {
alphanumeric: sortFn_alphanumeric,
datetime: sortFn_datetime,
text: sortFn_text,
},
})
+13
View File
@@ -133,6 +133,19 @@ export function formatPath(path: string) {
return path.replace(/\/{2,}/g, "/")
}
// Returns the URL only if it uses an http(s) scheme, else undefined. Guards
// against rendering attacker-controlled template metadata as a clickable
// javascript:/data: href.
export function safeExternalHref(url?: string): string | undefined {
if (!url) return undefined
try {
const parsed = new URL(url, window.location.origin)
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.href : undefined
} catch {
return undefined
}
}
export function joinIP(p?: ModelIP) {
if (p) {
if (p.ipv4_addr && p.ipv6_addr) {
+30 -2
View File
@@ -148,6 +148,10 @@
"Loading": "Laden",
"Services": "Services",
"DashboardOriginalHost": "Agent Verbindungsadresse [Domainname/IP:port]",
"DashboardHost": "Dashboard-Host für OAuth2-Callback [Domainname/IP:port]",
"DashboardHostHint": "Der öffentliche Host des Dashboards zum Erstellen der OAuth2-Callback-URL. Setzen Sie diesen Wert, wenn das Dashboard über eine andere Domain als die Agent-Verbindungsadresse erreichbar ist. Leer lassen, um dem Request-Host unverändert zu vertrauen.",
"ReservedHosts": "Reservierte Hosts (kommagetrennt; öffentliche/Reverse-Proxy-Domains, die Mitglieder nicht als NAT-Domain nutzen dürfen)",
"ReservedHostsHint": "Hinter einem Reverse-Proxy sieht das Dashboard seine eigene öffentliche Domain nicht und ist daher nicht automatisch geschützt. Tragen Sie hier jeden öffentlichen Eingangs-Hostnamen ein, sonst könnte ein Mitglied ihn als NAT-Domain registrieren und das Dashboard-Routing übernehmen.",
"NavigateTo": "Navigieren",
"Offline": "Offline",
"Interval": "Intervall",
@@ -161,7 +165,6 @@
"Login": "Anmelden",
"NewPassword": "Neues Passwort",
"FileManager": "Pseudo File Manager",
"ShowInService": "Zeige in Service",
"CommunityThemeDescription": "Dieses Design wird von der Community bereitgestellt, Benutzung auf eigene Gefahr",
"Language": "Sprache",
"FullIPNotification": "Vollständige IP-Adresse in Benachrichtigung anzeigen",
@@ -180,5 +183,30 @@
"EmptyNote": "Du hast noch keine Notizen.",
"BackToHome": "Zurück zur Startseite",
"OnAlert": "Server mit Warnung",
"EmptyText": "Text ist leer"
"EmptyText": "Text ist leer",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Scopes": "Scopes"
}
+64 -2
View File
@@ -83,7 +83,6 @@
"Confirm": "Confirm",
"ConfirmDeletion": "Confirm Deletion?",
"Services": "Services",
"ShowInService": "Show in Service",
"Coverages": {
"Excludes": "Excludes Specific Servers",
"Only": "Only Specific Servers",
@@ -136,9 +135,16 @@
"EditServerGroup": "Edit Server Group",
"CreateServerGroup": "Create Server Group",
"User": "User",
"Owner": "Owner",
"GlobalAgent": "Global Agent",
"UnknownUser": "Unknown user (#{{id}})",
"WAF": "Web Application Firewall",
"SiteName": "Site Name",
"DashboardOriginalHost": "Agent connecting address [domain name/IP:port]",
"DashboardHost": "Dashboard host for OAuth2 callback [domain name/IP:port]",
"DashboardHostHint": "The dashboard's public host used to build the OAuth2 callback URL. Set this when the dashboard is reached on a different domain than the agent connecting address. Leave empty to trust the request Host as-is.",
"ReservedHosts": "Reserved hosts (comma-separated; public/reverse-proxy domains members cannot use as NAT domains)",
"ReservedHostsHint": "Behind a reverse proxy the dashboard cannot see its own public domain, so it is not auto-protected. List every public entry hostname here, or a member could register it as a NAT domain and hijack dashboard routing.",
"ConfigTLS": "Use TLS to connect Agent",
"LoginFailed": "Login Failed",
"BruteForceAttackingToken": "Brute Force Attacking Token",
@@ -157,6 +163,7 @@
"DomainNotificationDays": "Domain Notification Days",
"ServerNotificationDays": "Server Notification Days",
"FullIPNotification": "Show Full IP Address in Notification Messages",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"EditService": "Edit Service",
"CreateService": "Create Service",
"EditTask": "Edit Task",
@@ -260,5 +267,60 @@
"ClipboardWriteFailed": "Clipboard write failed",
"PastedFromClipboard": "Pasted from clipboard",
"ClipboardReadFailed": "Clipboard read failed",
"FormatMetricUnits": "Format Metric Units"
"FormatMetricUnits": "Format Metric Units",
"Status": "Status",
"ServerID": "Server ID",
"CreatedAt": "Created At",
"BatchMoveServer": "Batch Move Server Owner",
"Servers": "Servers",
"ToUser": "To User",
"Move": "Move",
"Transfer": {
"Title": "Server Transfers",
"PageHint": "Tracks ownership transfers initiated from the batch-move action. Pending entries can be cancelled; terminal entries can be retried.",
"From": "From",
"To": "To",
"Initiator": "Initiator",
"LastError": "Last Error",
"Retry": "Retry",
"CancelRequested": "Cancellation requested",
"RetryRequested": "Retry requested",
"BatchSubmitted": "Batch transfer submitted",
"PendingCount": "{{count}} pending",
"PermissionDeniedCount": "{{count}} permission denied",
"AlreadyTransferringCount": "{{count}} already transferring",
"ServerNotFoundCount": "{{count}} not found",
"SameOwnerCount": "{{count}} already owned by target",
"AgentTooOldCount": "{{count}} agent too old (upgrade required)",
"StatusPending": "Pending",
"StatusVerified": "Verified",
"StatusFailed": "Failed",
"StatusTimeout": "Timeout",
"StatusCancelled": "Cancelled"
},
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Done": "Done",
"Scopes": "Scopes"
}
+29 -2
View File
@@ -51,7 +51,6 @@
"Coverage": "Cobertura",
"LocalService": "Servicio local",
"InstallCommands": "Comando de instalación",
"ShowInService": "Mostrar en servicio",
"Coverages": {
"Alarmed": "Ejecutado en el servidor que activó la alarma",
"Excludes": "Excluir servidores específicos",
@@ -158,6 +157,10 @@
"RequestType": "Tipo de solicitud",
"RequestBody": "Cuerpo de la solicitud",
"DashboardOriginalHost": "Dirección de conexión del Agent [nombre de dominio/IP:puerto]",
"DashboardHost": "Host del panel para el callback de OAuth2 [nombre de dominio/IP:puerto]",
"DashboardHostHint": "Host público del panel usado para construir la URL de callback de OAuth2. Configúralo cuando se acceda al panel en un dominio distinto al de la dirección de conexión del Agent. Déjalo vacío para confiar en el Host de la petición tal cual.",
"ReservedHosts": "Hosts reservados (separados por comas; dominios públicos/proxy inverso que los miembros no pueden usar como dominio NAT)",
"ReservedHostsHint": "Detrás de un proxy inverso, el panel no ve su propio dominio público, por lo que no se protege automáticamente. Indique aquí todos los nombres de host de entrada públicos; de lo contrario, un miembro podría registrarlo como dominio NAT y secuestrar el enrutamiento del panel.",
"ConfigTLS": "Usar TLS para conectar el Agent",
"CustomCodes": "Códigos personalizados (Style y Script)",
"CustomCodesDashboard": "Códigos personalizados para el Dashboard",
@@ -254,5 +257,29 @@
"CopiedToClipboard": "Copiar al portapapeles",
"ClipboardWriteFailed": "Error al escribir en el portapapeles",
"PastedFromClipboard": "Pegado del portapapeles",
"ClipboardReadFailed": "Falló la lectura del portapapeles"
"ClipboardReadFailed": "Falló la lectura del portapapeles",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copied": "Copied to clipboard",
"Scopes": "Scopes"
}
+26 -2
View File
@@ -64,7 +64,6 @@
"Confirm": "Confirmer",
"ConfirmDeletion": "Confirmer la suppression?",
"Services": "Services",
"ShowInService": "Montrer en service",
"MaximumLatency": "Délai maximum (ms)",
"MinimumLatency": "Délai minimum (millisecondes)",
"Command": "Commande",
@@ -92,5 +91,30 @@
"UserInvalid": "Lutilisateur est invalide",
"BlockByUser": "Bloqué par un administrateur",
"OnlineUser": "Utilisateur en ligne",
"UserId": "ID utilisateur"
"UserId": "ID utilisateur",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Scopes": "Scopes"
}
+27 -1
View File
@@ -21,5 +21,31 @@
"NoRowsAreSelected": "Non hai filas seleccionadas",
"ThisOperationIsUnrecoverable": "A operación non se pode desfacer!",
"TaskTriggeredSuccessfully": "A tarefa desencadeouse correctamente"
}
},
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Done": "Done",
"Scopes": "Scopes"
}
+62 -2
View File
@@ -83,7 +83,6 @@
"Confirm": "Konfirmasi",
"ConfirmDeletion": "Konfirmasi Penghapusan?",
"Services": "Layanan",
"ShowInService": "Tampilkan di Layanan",
"Coverages": {
"Excludes": "Kecualikan Server Tertentu",
"Only": "Hanya Server Tertentu",
@@ -139,6 +138,10 @@
"WAF": "Firewall Aplikasi Web",
"SiteName": "Nama Situs",
"DashboardOriginalHost": "Alamat koneksi Agen [nama domain/IP:port]",
"DashboardHost": "Host dashboard untuk callback OAuth2 [nama domain/IP:port]",
"DashboardHostHint": "Host publik dashboard yang dipakai untuk membangun URL callback OAuth2. Atur ini bila dashboard diakses pada domain yang berbeda dari alamat koneksi Agen. Kosongkan untuk mempercayai Host permintaan apa adanya.",
"ReservedHosts": "Host khusus (dipisahkan koma; domain publik/reverse-proxy yang tidak boleh dipakai anggota sebagai domain NAT)",
"ReservedHostsHint": "Di belakang reverse proxy, dasbor tidak melihat domain publiknya sendiri sehingga tidak terlindungi otomatis. Cantumkan setiap host entri publik di sini, atau anggota bisa mendaftarkannya sebagai domain NAT dan membajak perutean dasbor.",
"ConfigTLS": "Gunakan TLS untuk menghubungkan Agen",
"LoginFailed": "Gagal Masuk",
"BruteForceAttackingToken": "Token Serangan Brute Force",
@@ -255,5 +258,62 @@
"ClipboardWriteFailed": "Gagal menulis ke papan klip",
"PastedFromClipboard": "Ditempel dari papan klip",
"ClipboardReadFailed": "Gagal membaca papan klip",
"FormatMetricUnits": "Format Satuan Metrik"
"FormatMetricUnits": "Format Satuan Metrik",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copied": "Copied to clipboard",
"Scopes": "Scopes",
"Owner": "Pemilik",
"GlobalAgent": "Agen Global",
"UnknownUser": "Pengguna tidak dikenal (#{{id}})",
"Status": "Status",
"ServerID": "ID Server",
"CreatedAt": "Dibuat Pada",
"BatchMoveServer": "Pindahkan Pemilik Server Secara Batch",
"Servers": "Server",
"ToUser": "Ke Pengguna",
"Move": "Pindahkan",
"Transfer": {
"Title": "Transfer Server",
"PageHint": "Melacak transfer kepemilikan yang dimulai dari tindakan pindah batch. Entri yang masih tertunda dapat dibatalkan; entri terminal dapat dicoba lagi.",
"From": "Dari",
"To": "Ke",
"Initiator": "Pemrakarsa",
"LastError": "Kesalahan Terakhir",
"Retry": "Coba Lagi",
"CancelRequested": "Permintaan pembatalan dikirim",
"RetryRequested": "Permintaan coba lagi dikirim",
"BatchSubmitted": "Transfer batch dikirim",
"PendingCount": "{{count}} tertunda",
"PermissionDeniedCount": "{{count}} izin ditolak",
"AlreadyTransferringCount": "{{count}} sudah dalam proses transfer",
"ServerNotFoundCount": "{{count}} tidak ditemukan",
"SameOwnerCount": "{{count}} sudah dimiliki oleh target",
"AgentTooOldCount": "{{count}} agen terlalu lama versinya (perlu upgrade)",
"StatusPending": "Tertunda",
"StatusVerified": "Terverifikasi",
"StatusFailed": "Gagal",
"StatusTimeout": "Batas waktu habis",
"StatusCancelled": "Dibatalkan"
}
}
+30 -2
View File
@@ -73,7 +73,6 @@
"Confirm": "Confermo",
"ConfirmDeletion": "Confermi l'eliminazione?",
"Services": "Servizi",
"ShowInService": "Mostra in servizio",
"Coverages": {
"Only": "Solo server specifici",
"Excludes": "Escludi server specifici",
@@ -142,6 +141,10 @@
"CustomCodes": "Codice personalizzato (stili e script)",
"CustomCodesDashboard": "Codice personalizzato per dashboard",
"DashboardOriginalHost": "Indirizzo di ancoraggio dell'agente [nome dominio/IP:porta]",
"DashboardHost": "Host della dashboard per il callback OAuth2 [nome dominio/IP:porta]",
"DashboardHostHint": "Host pubblico della dashboard usato per costruire l'URL di callback OAuth2. Impostalo quando la dashboard è raggiungibile su un dominio diverso dall'indirizzo di connessione dell'agente. Lascia vuoto per fidarti dell'Host della richiesta così com'è.",
"ReservedHosts": "Host riservati (separati da virgola; domini pubblici/reverse-proxy che i membri non possono usare come dominio NAT)",
"ReservedHostsHint": "Dietro un reverse proxy il pannello non vede il proprio dominio pubblico, quindi non è protetto automaticamente. Elenca qui ogni hostname di ingresso pubblico, altrimenti un membro potrebbe registrarlo come dominio NAT e dirottare il routing del pannello.",
"ConfigTLS": "Usa TLS per connettere Agent",
"CustomPublicDNSNameserversforDDNS": "Server dei nomi DNS pubblici personalizzati per DDNS",
"WebRealIPHeader": "Intestazione della richiesta IP reale del frontend",
@@ -175,5 +178,30 @@
"OnlineUser": "Utente online",
"BlockIdentifier": "Identificatore del blocco",
"UserId": "ID utente",
"ConnectedAt": "Connesso alle"
"ConnectedAt": "Connesso alle",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Scopes": "Scopes"
}
+28 -1
View File
@@ -1 +1,28 @@
{}
{
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Done": "Done",
"Scopes": "Scopes"
}
+3
View File
@@ -0,0 +1,3 @@
{
"nezha": "Nezha Monitoramento"
}
+314
View File
@@ -0,0 +1,314 @@
{
"nezha": "Monitor-ul Nezha",
"theme": {
"light": "Mod luminos",
"dark": "Mod întunecat",
"system": "Monitorizeaza sistem-ul"
},
"Username": "Nume de utilizator",
"Password": "Parolă",
"InvalidUsernameOrPassword": "Nume de utilizator sau parolă incorectă",
"NetworkError": "Problemă cu rețeaua",
"LoginFirst": "Autentificati-vă, pentru a continua",
"CurrentTime": "Ora actuală",
"Results": {
"UsernameMin": "Numele de utilizator trebuie să conține cel puțin {{number}} de caractere lung.",
"PasswordRequired": "Parola nu poate fii goală.",
"ErrorFetchingResource": "Error Fetching Resource: {{error}}",
"SelectAtLeastOneServer": "Alege-ți cel puțin un server.",
"UnExpectedError": "Eroare neașteptată, verificați consola pentru mai multe informații.",
"ForceUpdate": "Upgradare forțată:",
"NoRowsAreSelected": "Nu este niciun rând selectat",
"ThisOperationIsUnrecoverable": "Această acțiune este ireversibilă!",
"TaskTriggeredSuccessfully": "Sarcina a fost declanșată cu succes",
"TheServerDoesNotOnline": "Server-ul nu exista sau nu a fost incă connectat",
"InstallHostRequired": "Adresa de connectare a agentului nu a fost incă completată in setări.",
"UnknownIdentifier": "Identificator necunoscut"
},
"Login": "Autentificare",
"Server": "Server",
"Service": "Service",
"Task": "Sarcină",
"Notification": "Notificare",
"DDNS": "DNS dinamic",
"NATT": "Traversare NAT",
"Group": "Grupă",
"Profile": "Profil",
"Settings": "Setări sistem",
"BackToHome": "Spre pagina principală",
"Logout": "Deconectare",
"NavigateTo": "Spre",
"SelectAPageToNavigateTo": "Du-te la pagina",
"Close": "Închide",
"Error": "Eroare",
"Name": "Nume",
"Version": "Versiune",
"Unknown": "Necunoscut",
"Enable": "Permite",
"HideForGuest": "Ascuns de vizitatori",
"InstallCommands": "Comanda de instalare",
"Terminal": "Terminal",
"Config": "Config",
"Note": "Notiță",
"Success": "Succes",
"Done": "Finalizat",
"Offline": "Offline",
"Failure": "Eșuare",
"Loading": "Se incarcă",
"NoResults": "Niciun resultat",
"Actions": "Acțiuni",
"EditServer": "Editează server-ul",
"Weight": "Greutate (cu căt mai mare este numărul, cu atâta de sus este afișat)",
"DDNSProfiles": "ID-urile de profil DDNS",
"SeparateWithComma": "(Separă cu virgulă)",
"Public": "Public",
"Private": "Privat",
"Submit": "Trimite",
"Target": "Obiectiv",
"Coverage": "Acoperire",
"CoverAll": "Acoperă-le pe toate",
"IgnoreAll": "Ignoră tot",
"OnAlert": "Server-ele alarmate",
"SpecificServers": "Server specific",
"Type": "Tip",
"Interval": "Interval",
"NotifierGroupID": "Notifier Group ID",
"Trigger": "La declanșare",
"TasksToTriggerOnAlert": "Sarcini, care sa fie declanșate la alerta",
"TasksToTriggerAfterRecovery": "Sarcini, care sa fie declanșate după recuperare",
"Add": "Adaugă",
"Delete": "Sterge",
"AdvancedJSON": "JSON avansat",
"Save": "Salvează",
"Confirm": "Confirmă",
"ConfirmDeletion": "Confirmă stergerea?",
"Services": "Servicii",
"ShowInService": "Deschide in servicii",
"Coverages": {
"Excludes": "Exclude servere specifice",
"Only": "Numai servere specifice",
"Alarmed": "Executate pe server-ul care declanșeaza alarma"
},
"EnableFailureNotification": "Activează notificare la eșec",
"MaximumLatency": "Delay-ul maximal (ms)",
"MinimumLatency": "Delay-ul minimal (ms)",
"EnableLatencyNotification": "Activează notificare de latență",
"EnableTriggerTask": "Activează sarcină de declanșare",
"CronExpression": "Expresii Cron",
"Command": "Commandă",
"NotifierGroup": "Grupa, care sa fie informată",
"SendSuccessNotification": "Trimite notificare de succes",
"LastExecution": "Ultima execuție",
"Result": "Rezultatul",
"Scheduled": "Sarcină programată",
"AlertRule": "Reguli de alertă",
"VerifyTLS": "Verifică TLS-ul",
"TriggerMode": "Modul de declanșare",
"Rules": "Reguli",
"RequestMethod": "Metoda de request",
"RequestHeader": "Header-ul de request",
"DoNotSendTestMessage": "Nu trimite mesaj de test",
"Always": "Mereu",
"Once": "Odată",
"Provider": "Furnizor",
"Domains": "Domenii",
"MaximumRetryAttempts": "Durata maximă pentru încercările de reluare",
"Refresh": "Reincarcă",
"CopyPath": "Copiază calea",
"Goto": "Accesează",
"UpdateProfile": "Actualizează profil-ul",
"NewUsername": "Nume de utilizator nou",
"OriginalPassword": "Parola originală",
"NewPassword": "Parola noua",
"EditDDNS": "Modifică DDNS-ul",
"CreateDDNS": "Crează un DDNS",
"RequestType": "Tip-ul de request",
"RequestBody": "Request Body",
"FileManager": "Pseudo manager de fișiere",
"Downloading": "Se descarcă",
"Uploading": "Se încarcă",
"EditNAT": "Modifică configurarea NAT",
"CreateNAT": "Crează o configurare NAT",
"LocalService": "Serviciu local",
"BindHostname": "Leagă numele de domeniu",
"EditServerGroup": "Modifică grupa de servere",
"CreateServerGroup": "Crează o grupă de servere",
"User": "Utilizator",
"Owner": "Propietar",
"GlobalAgent": "Agent global",
"UnknownUser": "Utilizator necunoscut (#{{id}})",
"WAF": "Web Application Firewall",
"SiteName": "Numele site-ului",
"DashboardOriginalHost": "Adresa de conectare a agentului [numele domeniului/IP:port]",
"DashboardHost": "Host-ul panoului pentru callback-ul OAuth2 [numele domeniului/IP:port]",
"DashboardHostHint": "Host-ul public al panoului folosit pentru a construi URL-ul de callback OAuth2. Setați-l când panoul este accesat pe un domeniu diferit de adresa de conectare a agentului. Lăsați gol pentru a avea încredere în Host-ul cererii ca atare.",
"ReservedHosts": "Host-uri reservate (separate prin virgulă; domeniile publice sau ale reverse proxy-urilor nu pot fi utilizate ca domenii NAT)",
"ReservedHostsHint": "În spatele unui reverse proxy, dashboard-ul nu își poate vedea propriul domeniu public, prin urmare nu este protejat automat. Trebuie să menționați aici toate hostname-urile ale intrărilor publice; în caz contrar, un membru ar putea să le înregistreze ca domenii NAT și să deturneze rutarea către dashboard.",
"ConfigTLS": "Folosește TLS pentru a conecta agentul",
"LoginFailed": "Autentificarea a eșuat",
"BruteForceAttackingToken": "Brute force atacează token-ul",
"BruteForceAttackingAgentSecret": "Brute force atacează agentul secret",
"Language": "Limbă",
"CustomCodes": "Cod custom (Style și script)",
"CustomCodesDashboard": "Cod custom pentru dashboard",
"CustomPublicDNSNameserversforDDNS": "Servere de nume DNS publice personalizate pentru DDNS",
"WebRealIPHeader": "adresa reală IP frontend request header",
"AgentRealIPHeader": "adresa reală IP a agentului request header",
"UseDirectConnectingIP": "Folosește conexiune IP directă",
"IPChangeNotification": "Notificare la schimbare de adresa IP",
"FullIPNotification": "Afișează intreaga adresa IP in notificări",
"EnableMCP": "Activează endpoint-uri MCP (implicit dezactivate; Verificați mai întâi domeniile de aplicare ale tokenurilor API și lista de servere permise)",
"EditService": "Modifică serviciul",
"CreateService": "Creează un serviciu",
"EditTask": "Modifică sarcina",
"CreateTask": "Creează sarcină",
"CreateNotifier": "Creează un notificator",
"EditNotifier": "Modifică notificatorul",
"EditAlertRule": "Modifică regurile de alertare",
"CreateAlertRule": "Creează reguli de alertare",
"EditNotifierGroup": "Modifică grupa, care va fii notificată",
"CreateNotifierGroup": "Creează o grupă de notificare",
"NewUser": "Utilizator nou",
"Count": "Numără",
"LastBlockReason": "Motivul ultimului block",
"LastBlockTime": "Ultima perioadă de ban",
"Theme": "Temă",
"Author": "Autor",
"Repository": "Repository",
"Community": "Comunitate",
"Official": "Oficial",
"CommunityThemeWarning": "Utilizezi momentan o temă creată de către comunitate",
"CommunityThemeDescription": "Această temă este dezvoltată de către comunitate, o folosiți pe propria răspundere",
"Cancel": "Anulare",
"EnableDDNS": "Activează DDNS",
"PushSuccessful": "Push dacă a fost cu sucess",
"GrpcAuthFailed": "Autentificarea gRPC a eșuat",
"APITokenInvalid": "Token-ul API este invalid",
"UserInvalid": "Utilizator-ul este invalid",
"BlockByUser": "Blocat de către administrator",
"BlockIdentifier": "Identificator de block",
"UserId": "User ID",
"ConnectedAt": "Ultima dată conectat pe",
"OnlineUser": "Utilizator activ",
"Total": "Total",
"ConfirmBlock": "Confirmă blocarea",
"RejectPassword": "Respinge logarea cu parolă",
"EmptyText": "Text este gol",
"EmptyNote": "Nu ai nici-o notiță.",
"OverrideDDNSDomains": "Suprascriere domenii DDNS (per configurație)",
"EditServerConfig": "Modifică configurarea server-ului",
"Option": "Optiune",
"Value": "Valoare",
"Preview": "Previzualizare",
"PublicNote": {
"Label": "Notiță publică",
"Billing": "Facturare",
"Plan": "Tarif",
"StartDate": "Data de incepere",
"EndDate": "Date de finalizare",
"AutoRenewal": "Reînnoire automată",
"Cycle": "Ciclu",
"Amount": "Sumă",
"Bandwidth": "Bandwidth",
"TrafficVolume": "Volumul traficului",
"TrafficType": "Tipul traficului",
"IPv4": "IPv4",
"IPv6": "IPv6",
"NetworkRoute": "Rută network",
"Extra": "Extra",
"Enabled": "Activat",
"Disabled": "Dezactivat",
"Inbound": "Detaliat",
"Both": "Amândouă",
"Day": "Zi",
"Week": "Săptămână",
"Month": "Lună",
"Year": "An",
"NoExpiry": "Nici-o expirare",
"SetNoExpiry": "Setează nici-o expirare",
"CancelNoExpiry": "Anulează nici-o expirare",
"Free": "Liber",
"PayAsYouGo": "Plătești pe măsură ce consumi (Pay as you go)",
"CommaSeparated": "Separă mai multe obiecte cu virgulă",
"Has": "Are",
"None": "Nimica",
"CustomFields": "Câmpuri personalizate",
"ClearDate": "Sterge data",
"Clear": "Curăță",
"RawText": "Raw Text"
},
"Validation": {
"InvalidDate": "Dată invalidă",
"MustBe0Or1": "Trebuie să fie 0 sau 1",
"MustBeDayWeekMonthYear": "Trebuie să fie Zi/Săptămână/Lună/An",
"MustBe1Or2": "Trebuie să fie 1 sau 2",
"DigitsOnly": "Doar cifre",
"InvalidForm": "Formular invalid",
"InvalidJSON": "JSON invalid"
},
"AlertRules": {
"CoverAllServers": "Monitorizează toate server-ele",
"IgnoreAllSelectSpecific": "Ignoră totul, selectează server specifice",
"IgnoreHint": "{{server}} ID: adevărat/fals",
"IgnoreExample": "de exemplu, {\"1\": true, \"2\": false}"
},
"Search": "Caută...",
"Format": "Formatează",
"Formatted": "Formatat",
"Copy": "Copiază",
"Paste": "Lipește",
"CopiedToClipboard": "Copiat in clipboard",
"ClipboardWriteFailed": "Eșec la scrierea în clipboard",
"PastedFromClipboard": "Lipit din clipboard",
"ClipboardReadFailed": "Eșec la citirea clipboard-ului",
"FormatMetricUnits": "Format unități metrice",
"Status": "Status",
"ServerID": "ID-ul server-ului",
"CreatedAt": "Creat la",
"BatchMoveServer": "Mutarea în bloc a proprietarului serverului",
"Servers": "Servere",
"ToUser": "Spre utilizator",
"Move": "Transferă",
"Transfer": {
"Title": "Transfere de server",
"PageHint": "Urmărește transferurile de proprietate inițiate prin acțiunea de mutare în bloc. Înregistrările în așteptare pot fi anulate, iar cele finalizate pot fi reluate.",
"From": "De la",
"To": "Pentru",
"Initiator": "Inițiator",
"LastError": "Ultima eroare",
"Retry": "Reîncearcă",
"CancelRequested": "Anularea a fost solicitată",
"RetryRequested": "Reîncercarea a fost solicitată",
"BatchSubmitted": "Transferul în bloc a fost trimis",
"PendingCount": "{{count}} în așteptare",
"PermissionDeniedCount": "{{count}} a fost refuzat accesul",
"AlreadyTransferringCount": "{{count}} se transferă deja",
"ServerNotFoundCount": "{{count}} nu au fost găsite",
"SameOwnerCount": "{{count}} sunt deja deținute de destinatar",
"AgentTooOldCount": "{{count}} agenți sunt prea vechi (este necesară o actualizare)",
"StatusPending": "În așteptare",
"StatusVerified": "Verificat",
"StatusFailed": "A eșuat",
"StatusTimeout": "Timeout",
"StatusCancelled": "Anulat"
},
"ApiTokens": "Token-uri API",
"CreateApiToken": "Creează un token API",
"CreateApiTokenDescription": "Tokenurile autentifică MCP și clienții externi în numele dumneavoastră. Acestea nu pot depăși limitele permisiunilor dumneavoastră.",
"ConfirmDeleteApiToken": "Revocă API token-ul '{{name}}'? Această acțiune eset ireversibilă.",
"ApiTokenRevoked": "API token-ul a fost revocat.",
"ApiTokenCreated": "API token-ul a fost creat",
"ApiTokenRevealOnce": "Copiază acest token acum. Nu va mai fi afișat niciodată.",
"ApiTokenStoreSafely": "Tratează acest token ca pe o parolă. Oricine îl deține poate acționa în numele tău în limitele de aplicare ale acestuia.",
"ApiTokenServers": "Servere",
"ApiTokenAllServers": "Toate permise",
"ApiTokenNever": "Niciodată",
"ApiTokenExpiresAt": "Expiră",
"ApiTokenLastUsed": "Ultima dată utilizat",
"ApiTokenServerIDs": "Limitare la ID-urile serverelor (opțional)",
"ApiTokenServerIDsPlaceholder": "separate prin virgulă, de exemplu 1,2,3",
"ApiTokenExpiresInDays": "Expiră in (zile, 0=niciodată)",
"Revoke": "Revocă",
"Copied": "Copiat în clipboard",
"Scopes": "Scope-uri"
}
+30 -2
View File
@@ -42,6 +42,10 @@
"Credential": "Учетные данные",
"CommunityThemeDescription": "Эта тема предоставлена сообществом, используйте на свой страх и риск",
"DashboardOriginalHost": "Адрес подключения агента [домен/IP:порт]",
"DashboardHost": "Хост панели для OAuth2 callback [домен/IP:порт]",
"DashboardHostHint": "Публичный хост панели, используемый для формирования URL обратного вызова OAuth2. Укажите его, если панель доступна на домене, отличном от адреса подключения агента. Оставьте пустым, чтобы доверять Host из запроса как есть.",
"ReservedHosts": "Зарезервированные хосты (через запятую; публичные/реверс-прокси домены, которые участники не могут использовать как NAT-домен)",
"ReservedHostsHint": "За реверс-прокси панель не видит собственный публичный домен и не защищается автоматически. Укажите здесь все публичные входные хосты, иначе участник сможет зарегистрировать такой домен как NAT-домен и перехватить маршрутизацию панели.",
"Error": "Ошибка",
"TriggerMode": "Режим срабатывания",
"Once": "Один раз",
@@ -176,7 +180,6 @@
"Language": "Язык",
"ConfirmDeletion": "Подтвердить удаление?",
"OriginalPassword": "Старый пароль",
"ShowInService": "Показывать в Сервисе",
"EnableFailureNotification": "Включить уведомления об ошибках",
"CreateNAT": "Создать NAT-конфигурацию",
"Scheduled": "Запланированные задачи",
@@ -185,5 +188,30 @@
"IPChangeNotification": "Уведомление об изменении IP",
"CreateAlertRule": "Создать правила оповещений",
"UserId": "ID пользователя",
"NewUser": "Новый пользователь"
"NewUser": "Новый пользователь",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Scopes": "Scopes"
}
+30 -2
View File
@@ -25,7 +25,6 @@
},
"ConfirmDeletion": "நீக்குதலை உறுதிப்படுத்தவா?",
"Services": "சேவைகள்",
"ShowInService": "சேவையில் காட்டு",
"Coverages": {
"Excludes": "குறிப்பிட்ட சேவையகங்களை விலக்குகிறது",
"Only": "குறிப்பிட்ட சேவையகங்கள் மட்டுமே",
@@ -135,6 +134,10 @@
"WAF": "வலை பயன்பாடு ஃபயர்வால்",
"SiteName": "தளத்தின் பெயர்",
"DashboardOriginalHost": "முகவரியை இணைக்கும் முகவரி [டொமைன் பெயர்/ஐபி: போர்ட்]",
"DashboardHost": "OAuth2 கால்பேக்கிற்கான டாஷ்போர்டு ஹோஸ்ட் [டொமைன் பெயர்/ஐபி: போர்ட்]",
"DashboardHostHint": "OAuth2 கால்பேக் URL-ஐ உருவாக்கப் பயன்படும் டாஷ்போர்டின் பொது ஹோஸ்ட். ஏஜெண்ட் இணைப்பு முகவரியிலிருந்து வேறுபட்ட டொமைனில் டாஷ்போர்டு அணுகப்பட்டால் இதை அமைக்கவும். கோரிக்கையின் Host-ஐ அப்படியே நம்ப காலியாக விடவும்.",
"ReservedHosts": "ஒதுக்கப்பட்ட ஹோஸ்ட்கள் (கமாவால் பிரிக்கப்பட்டது; உறுப்பினர்கள் NAT டொமைனாகப் பயன்படுத்த முடியாத பொது/ரிவர்ஸ்-ப்ராக்ஸி டொமைன்கள்)",
"ReservedHostsHint": "ரிவர்ஸ்-ப்ராக்ஸிக்குப் பின்னால் டாஷ்போர்டு அதன் சொந்த பொது டொமைனைப் பார்க்க முடியாது, எனவே அது தானாகப் பாதுகாக்கப்படாது. அனைத்து பொது நுழைவு ஹோஸ்ட்களையும் இங்கே பட்டியலிடுங்கள்; இல்லையெனில் ஒரு உறுப்பினர் அதை NAT டொமைனாகப் பதிவுசெய்து டாஷ்போர்டு வழித்தடத்தைக் கடத்தலாம்.",
"ConfigTLS": "முகவரை இணைக்க TLS ஐப் பயன்படுத்தவும்",
"LoginFailed": "உள்நுழைவு தோல்வியடைந்தது",
"BruteForceAttackingToken": "மிருகத்தனமான ஆற்றல் டோக்கனைத் தாக்குகிறது",
@@ -180,5 +183,30 @@
"ConfirmBlock": "தொகுதி உறுதிப்படுத்தவும்",
"RejectPassword": "கடவுச்சொல் உள்நுழைவை நிராகரிக்கவும்",
"EmptyText": "உரை காலியாக உள்ளது",
"EmptyNote": "உங்களிடம் எந்த குறிப்பும் இல்லை."
"EmptyNote": "உங்களிடம் எந்த குறிப்பும் இல்லை.",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Scopes": "Scopes"
}
+26 -2
View File
@@ -83,7 +83,6 @@
"Confirm": "Підтвердити",
"ConfirmDeletion": "Підтвердити видалення?",
"Services": "Сервіси",
"ShowInService": "Показувати у Сервісі",
"Coverages": {
"Excludes": "Виключити певні сервери",
"Only": "Тільки певні сервери",
@@ -127,5 +126,30 @@
"RequestType": "Тип запиту",
"RequestBody": "Тіло запиту",
"FileManager": "Псевдо Менеджер файлів",
"Downloading": "Завантаження"
"Downloading": "Завантаження",
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
"ApiTokens": "API Tokens",
"CreateApiToken": "Create API token",
"CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.",
"ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.",
"ApiTokenRevoked": "API token revoked.",
"ApiTokenCreated": "API token created",
"ApiTokenRevealOnce": "Copy this token now. It will never be shown again.",
"ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.",
"ApiTokenServers": "Servers",
"ApiTokenAllServers": "all permitted",
"ApiTokenNever": "never",
"ApiTokenExpiresAt": "Expires",
"ApiTokenLastUsed": "Last used",
"ApiTokenServerIDs": "Restrict to server IDs (optional)",
"ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3",
"ApiTokenExpiresInDays": "Expires in (days, 0 = never)",
"ApiTokenScopeRequired": "At least one scope is required.",
"ApiTokenServersInvalid": "Server IDs must be positive integers.",
"ApiTokenExpiryInvalid": "Days must be between 0 and 3650.",
"NameRequired": "Name is required.",
"Revoke": "Revoke",
"Copy": "Copy",
"Copied": "Copied to clipboard",
"Scopes": "Scopes"
}
+62 -2
View File
@@ -83,7 +83,6 @@
"Confirm": "确认",
"ConfirmDeletion": "确认删除?",
"Services": "服务",
"ShowInService": "服务中显示",
"Coverages": {
"Excludes": "排除特定服务器",
"Only": "仅特定服务器",
@@ -146,12 +145,19 @@
"EditNotifierGroup": "编辑通知分组",
"CreateNotifierGroup": "创建通知分组",
"User": "用户",
"Owner": "所属用户",
"GlobalAgent": "全局 Agent",
"UnknownUser": "未知用户 (#{{id}})",
"WAF": "Web应用防火墙",
"SiteName": "站点名称",
"Language": "语言",
"CustomCodes": "自定义代码(样式和脚本)",
"CustomCodesDashboard": "仪表板的自定义代码",
"DashboardOriginalHost": "Agent对接地址【域名/IP:端口】",
"DashboardHost": "面板 OAuth2 回调域名【域名/IP:端口】",
"DashboardHostHint": "用于生成 OAuth2 回调地址的面板对外域名。当面板访问域名与 Agent 对接地址不同时设置此项。留空则信任请求 Host,不做强制重写。",
"ReservedHosts": "保留 Host(逗号分隔;反代/公网域名,成员不可注册为 NAT 域名)",
"ReservedHostsHint": "反代部署下面板看不到自身的对外域名,无法自动保护。请在此列出所有公网入口域名,否则成员可将其注册为 NAT 域名抢占面板路由。",
"ConfigTLS": "Agent 使用 TLS 连接",
"CustomPublicDNSNameserversforDDNS": "DDNS 的自定义公共 DNS 名称服务器",
"WebRealIPHeader": "前端真实IP请求头",
@@ -164,6 +170,7 @@
"DomainNotificationDays": "域名到期提醒天数",
"ServerNotificationDays": "VPS到期提醒天数",
"FullIPNotification": "在通知消息中显示完整的 IP 地址",
"EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)",
"LoginFailed": "登录失败",
"BruteForceAttackingToken": "暴力攻击令牌",
"BruteForceAttackingAgentSecret": "暴力攻击代理秘密",
@@ -260,5 +267,58 @@
"ClipboardWriteFailed": "无法写入剪贴板",
"PastedFromClipboard": "已从剪贴板粘贴",
"ClipboardReadFailed": "无法读取剪贴板",
"FormatMetricUnits": "格式化数据单位"
"FormatMetricUnits": "格式化数据单位",
"Status": "状态",
"ServerID": "服务器 ID",
"CreatedAt": "创建时间",
"BatchMoveServer": "批量转移服务器所有者",
"Servers": "服务器",
"ToUser": "目标用户",
"Move": "转移",
"Transfer": {
"Title": "服务器转移",
"PageHint": "跟踪由批量转移操作发起的所有权转移。待处理条目可以取消;已终止条目可以重试。",
"From": "源用户",
"To": "目标用户",
"Initiator": "发起者",
"LastError": "最近错误",
"Retry": "重试",
"CancelRequested": "已请求取消",
"RetryRequested": "已请求重试",
"BatchSubmitted": "批量转移已提交",
"PendingCount": "{{count}} 个待处理",
"PermissionDeniedCount": "{{count}} 个权限不足",
"AlreadyTransferringCount": "{{count}} 个正在转移中",
"ServerNotFoundCount": "{{count}} 个未找到",
"SameOwnerCount": "{{count}} 个已属于目标用户",
"AgentTooOldCount": "{{count}} 个 agent 版本过低(需升级)",
"StatusPending": "待处理",
"StatusVerified": "已验证",
"StatusFailed": "已失败",
"StatusTimeout": "已超时",
"StatusCancelled": "已取消"
},
"ApiTokens": "API 令牌",
"CreateApiToken": "创建 API 令牌",
"CreateApiTokenDescription": "令牌用于 MCP 和外部客户端以你的身份调用 API,权限不能超过你本人。",
"ConfirmDeleteApiToken": "确定吊销 API 令牌 '{{name}}'?此操作不可撤销。",
"ApiTokenRevoked": "API 令牌已吊销。",
"ApiTokenCreated": "API 令牌已创建",
"ApiTokenRevealOnce": "请立即复制此令牌,离开后将无法再次查看。",
"ApiTokenStoreSafely": "请像密码一样妥善保管。任何持有者都可在 scope 内以你的身份操作。",
"ApiTokenServers": "服务器",
"ApiTokenAllServers": "全部可访问",
"ApiTokenNever": "永不",
"ApiTokenExpiresAt": "过期时间",
"ApiTokenLastUsed": "最近使用",
"ApiTokenServerIDs": "限定服务器 ID(可选)",
"ApiTokenServerIDsPlaceholder": "逗号分隔,例如 1,2,3",
"ApiTokenExpiresInDays": "有效期(天,0 = 永不过期)",
"ApiTokenScopeRequired": "至少选择一个 scope。",
"ApiTokenServersInvalid": "服务器 ID 必须为正整数。",
"ApiTokenExpiryInvalid": "天数必须在 0 到 3650 之间。",
"NameRequired": "名称必填。",
"Revoke": "吊销",
"Copied": "已复制到剪贴板",
"Scopes": "权限"
}
+65 -2
View File
@@ -75,7 +75,6 @@
"Confirm": "確認",
"ConfirmDeletion": "確認刪除?",
"Services": "服務",
"ShowInService": "服務中顯示",
"Coverages": {
"Only": "僅特定伺服器",
"Excludes": "排除特定伺服器",
@@ -138,12 +137,19 @@
"EditNotifierGroup": "編輯通知分組",
"CreateNotifierGroup": "建立通知分組",
"User": "使用者",
"Owner": "所屬使用者",
"GlobalAgent": "全域 Agent",
"UnknownUser": "未知使用者 (#{{id}})",
"WAF": "Web應用防火牆",
"SiteName": "網站名稱",
"Language": "語言",
"CustomCodes": "自訂程式碼(樣式和腳本)",
"CustomCodesDashboard": "儀表板的自訂程式碼",
"DashboardOriginalHost": "Agent對接位址【網域名稱/IP:連接埠】",
"DashboardHost": "面板 OAuth2 回呼網域【網域名稱/IP:連接埠】",
"DashboardHostHint": "用於產生 OAuth2 回呼位址的面板對外網域。當面板存取網域與 Agent 對接位址不同時設定此項。留空則信任請求 Host,不做強制重寫。",
"ReservedHosts": "保留 Host(逗號分隔;反代/公網網域,成員不可註冊為 NAT 網域)",
"ReservedHostsHint": "反代部署下面板看不到自身的對外網域,無法自動保護。請在此列出所有公網入口網域,否則成員可將其註冊為 NAT 網域搶佔面板路由。",
"ConfigTLS": "Agent 使用 TLS 連線",
"CustomPublicDNSNameserversforDDNS": "DDNS 的自訂公共 DNS 名稱伺服器",
"WebRealIPHeader": "前端真實IP請求頭",
@@ -186,5 +192,62 @@
"Option": "選項",
"Value": "值",
"Preview": "預覽",
"FormatMetricUnits": "格式化資料單位"
"FormatMetricUnits": "格式化資料單位",
"Status": "狀態",
"ServerID": "伺服器 ID",
"CreatedAt": "建立時間",
"BatchMoveServer": "批次轉移伺服器擁有者",
"Servers": "伺服器",
"ToUser": "目標使用者",
"Move": "轉移",
"Transfer": {
"Title": "伺服器轉移",
"PageHint": "追蹤由批次轉移操作發起的擁有權轉移。待處理項目可以取消;已終止項目可以重試。",
"From": "來源使用者",
"To": "目標使用者",
"Initiator": "發起者",
"LastError": "最近錯誤",
"Retry": "重試",
"CancelRequested": "已請求取消",
"RetryRequested": "已請求重試",
"BatchSubmitted": "批次轉移已提交",
"PendingCount": "{{count}} 個待處理",
"PermissionDeniedCount": "{{count}} 個權限不足",
"AlreadyTransferringCount": "{{count}} 個正在轉移中",
"ServerNotFoundCount": "{{count}} 個未找到",
"SameOwnerCount": "{{count}} 個已屬於目標使用者",
"AgentTooOldCount": "{{count}} 個 agent 版本過舊(需升級)",
"StatusPending": "待處理",
"StatusVerified": "已驗證",
"StatusFailed": "已失敗",
"StatusTimeout": "已逾時",
"StatusCancelled": "已取消"
},
"EnableMCP": "啟用 MCP 端點(預設關閉;啟用前請先檢查 API token 範圍與伺服器允許清單)",
"ApiTokens": "API Token",
"CreateApiToken": "建立 API token",
"CreateApiTokenDescription": "Token 會代表你對 MCP 與外部用戶端進行驗證,其權限不會超過你本身的權限。",
"ConfirmDeleteApiToken": "撤銷 API token「{{name}}」?此操作無法復原。",
"ApiTokenRevoked": "API token 已撤銷。",
"ApiTokenCreated": "API token 已建立",
"ApiTokenRevealOnce": "請立即複製此 token,之後將不再顯示。",
"ApiTokenStoreSafely": "請將此 token 視為密碼。任何持有者都能在其範圍內代表你操作。",
"ApiTokenServers": "伺服器",
"ApiTokenAllServers": "全部允許",
"ApiTokenNever": "永不",
"ApiTokenExpiresAt": "到期時間",
"ApiTokenLastUsed": "最後使用",
"ApiTokenServerIDs": "限制伺服器 ID(選填)",
"ApiTokenServerIDsPlaceholder": "以逗號分隔,例如 1,2,3",
"ApiTokenExpiresInDays": "有效天數(0 = 永不過期)",
"ApiTokenScopeRequired": "至少需要一個範圍。",
"ApiTokenServersInvalid": "伺服器 ID 必須為正整數。",
"ApiTokenExpiryInvalid": "天數必須介於 0 到 3650 之間。",
"NameRequired": "名稱為必填。",
"Revoke": "撤銷",
"Copy": "複製",
"Copied": "已複製到剪貼簿",
"Scopes": "範圍",
"InvalidUsernameOrPassword": "使用者名稱或密碼無效",
"NetworkError": "網路錯誤"
}
+12
View File
@@ -23,9 +23,12 @@ import ServerPage from "./routes/server"
import ServerGroupPage from "./routes/server-group"
import ServicePage from "./routes/service"
import SettingsPage from "./routes/settings"
import TransferPage from "./routes/transfer"
import UserPage from "./routes/user"
import WAFPage from "./routes/waf"
import DomainPage from "./routes/domain"
import ApiTokensPage from "./routes/api-tokens"
const router = createBrowserRouter([
{
path: "/dashboard",
@@ -143,8 +146,17 @@ const router = createBrowserRouter([
path: "/dashboard/domain",
element: <DomainPage />,
},
{
path: "/dashboard/settings/api-tokens",
element: <ApiTokensPage />,
},
{
path: "/dashboard/transfer",
element: <TransferPage />,
},
],
},
])
createRoot(document.getElementById("root")!).render(<RouterProvider router={router} />)
+5 -5
View File
@@ -14,8 +14,9 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { selectableTableFeatures } from "@/lib/table"
import { ModelAlertRule, triggerModes } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -37,7 +38,7 @@ export default function AlertRulePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<ModelAlertRule>[] = [
const columns: ColumnDef<typeof selectableTableFeatures, ModelAlertRule>[] = [
{
id: "select",
header: ({ table }) => (
@@ -57,7 +58,6 @@ export default function AlertRulePage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -131,10 +131,10 @@ export default function AlertRulePage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+350
View File
@@ -0,0 +1,350 @@
import {
ApiTokenCreateResponse,
ApiTokenView,
SCOPE_OPTIONS,
createApiToken,
deleteApiToken,
listApiTokens,
parseExpiresInDaysInput,
parseServerIDsInput,
} from "@/api/api-tokens"
import { SettingsTab } from "@/components/settings-tab"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { useAuth } from "@/hooks/useAuth"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import useSWR from "swr"
export default function ApiTokensPage() {
const { t } = useTranslation()
const { profile } = useAuth()
const isAdmin = profile?.role === 0
const { data, mutate, isLoading, error } = useSWR<ApiTokenView[]>(
"/api/v1/api-tokens",
listApiTokens,
)
useEffect(() => {
if (!error) return
toast(t("Error"), {
description: t("Results.ErrorFetchingResource", {
error: (error as Error)?.message ?? String(error),
}),
})
}, [error, t])
const [createOpen, setCreateOpen] = useState(false)
const [revealed, setRevealed] = useState<ApiTokenCreateResponse | null>(null)
const handleDelete = async (id: number, name: string) => {
if (!window.confirm(t("ConfirmDeleteApiToken", { name }))) return
try {
await deleteApiToken(id)
toast(t("ApiTokenRevoked"))
await mutate()
} catch (e: any) {
toast(t("Error"), { description: e.message })
}
}
return (
<div className="px-3">
<SettingsTab className="mt-6 w-full" />
<div className="flex mt-4 mb-4 items-center justify-between">
<h2 className="text-lg font-semibold">{t("ApiTokens")}</h2>
<Button onClick={() => setCreateOpen(true)}>{t("CreateApiToken")}</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("Name")}</TableHead>
<TableHead>{t("Scopes")}</TableHead>
<TableHead>{t("ApiTokenServers")}</TableHead>
<TableHead>{t("ApiTokenExpiresAt")}</TableHead>
<TableHead>{t("ApiTokenLastUsed")}</TableHead>
<TableHead>{t("Actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
{t("Loading")}...
</TableCell>
</TableRow>
) : !data || data.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="h-24 text-center text-muted-foreground"
>
{t("NoResults")}
</TableCell>
</TableRow>
) : (
data.map((tok) => (
<TableRow key={tok.id}>
<TableCell>{tok.name}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(tok.scopes ?? []).map((s) => (
<span
key={s}
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
>
{s}
</span>
))}
</div>
</TableCell>
<TableCell className="text-xs">
{tok.server_ids?.length
? tok.server_ids.join(", ")
: t("ApiTokenAllServers")}
</TableCell>
<TableCell className="text-xs">
{tok.expires_at
? new Date(tok.expires_at).toLocaleString()
: t("ApiTokenNever")}
</TableCell>
<TableCell className="text-xs">
{tok.last_used_at
? `${new Date(tok.last_used_at).toLocaleString()} (${tok.last_used_ip ?? "?"})`
: t("ApiTokenNever")}
</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(tok.id, tok.name)}
>
{t("Revoke")}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<CreateApiTokenDialog
open={createOpen}
onOpenChange={setCreateOpen}
isAdmin={isAdmin}
onCreated={(res) => {
setRevealed(res)
mutate()
}}
/>
<RevealedTokenDialog token={revealed} onClose={() => setRevealed(null)} />
</div>
)
}
function CreateApiTokenDialog({
open,
onOpenChange,
isAdmin,
onCreated,
}: {
open: boolean
onOpenChange: (v: boolean) => void
isAdmin: boolean
onCreated: (res: ApiTokenCreateResponse) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState("")
const [scopes, setScopes] = useState<string[]>([])
const [serverIDs, setServerIDs] = useState("")
const [expiresInDays, setExpiresInDays] = useState<string>("90")
const [submitting, setSubmitting] = useState(false)
const toggleScope = (s: string) => {
setScopes((cur) => (cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]))
}
const submit = async () => {
if (!name.trim()) {
toast(t("Error"), { description: t("NameRequired") })
return
}
if (scopes.length === 0) {
toast(t("Error"), { description: t("ApiTokenScopeRequired") })
return
}
const parsedRes = parseServerIDsInput(serverIDs)
if (!parsedRes.ok) {
toast(t("Error"), { description: t("ApiTokenServersInvalid") })
return
}
const parsedServers = parsedRes.value
const expRes = parseExpiresInDaysInput(expiresInDays)
if (!expRes.ok) {
toast(t("Error"), { description: t("ApiTokenExpiryInvalid") })
return
}
const expDays = expRes.value
setSubmitting(true)
try {
const res = await createApiToken({
name: name.trim(),
scopes,
server_ids: parsedServers,
expires_in_days: expDays,
})
onCreated(res)
onOpenChange(false)
setName("")
setScopes([])
setServerIDs("")
setExpiresInDays("90")
} catch (e: any) {
toast(t("Error"), { description: e.message })
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("CreateApiToken")}</DialogTitle>
<DialogDescription>{t("CreateApiTokenDescription")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="name">{t("Name")}</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-2">
<Label>{t("Scopes")}</Label>
<div className="max-h-72 space-y-2 overflow-y-auto pr-2">
{SCOPE_OPTIONS.map((s) => {
const adminOnly =
s.value === "nezha:*" || s.value === "nezha:admin:*"
const disabled = adminOnly && !isAdmin
return (
<label
key={s.value}
className={`flex items-start gap-2 text-sm ${disabled ? "opacity-40" : ""}`}
>
<Checkbox
checked={scopes.includes(s.value)}
onCheckedChange={() =>
!disabled && toggleScope(s.value)
}
disabled={disabled}
/>
<div className="flex flex-col">
<span className="font-mono text-xs">{s.value}</span>
<span className="text-xs text-muted-foreground">
{s.desc}
</span>
</div>
</label>
)
})}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="server-ids">{t("ApiTokenServerIDs")}</Label>
<Input
id="server-ids"
placeholder={t("ApiTokenServerIDsPlaceholder")}
value={serverIDs}
onChange={(e) => setServerIDs(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="expires">{t("ApiTokenExpiresInDays")}</Label>
<Input
id="expires"
type="number"
min={0}
max={3650}
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">{t("Cancel")}</Button>
</DialogClose>
<Button onClick={submit} disabled={submitting}>
{submitting ? t("Loading") + "..." : t("CreateApiToken")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RevealedTokenDialog({
token,
onClose,
}: {
token: ApiTokenCreateResponse | null
onClose: () => void
}) {
const { t } = useTranslation()
return (
<Dialog open={!!token} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("ApiTokenCreated")}</DialogTitle>
<DialogDescription>{t("ApiTokenRevealOnce")}</DialogDescription>
</DialogHeader>
{token && (
<div className="space-y-3">
<div className="rounded border border-amber-500/40 bg-amber-100 dark:bg-amber-950/40 p-3 text-sm">
{t("ApiTokenStoreSafely")}
</div>
<code className="block break-all rounded bg-muted p-3 font-mono text-xs">
{token.token}
</code>
<Button
variant="secondary"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(token.token)
toast(t("Copied"))
}}
>
{t("Copy")}
</Button>
</div>
)}
<DialogFooter>
<Button onClick={onClose}>{t("Done")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+5 -5
View File
@@ -14,9 +14,10 @@ import {
TableRow,
} from "@/components/ui/table"
import { IconButton } from "@/components/xui/icon-button"
import { selectableTableFeatures } from "@/lib/table"
import { ModelCron } from "@/types"
import { cronTypes } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -36,7 +37,7 @@ export default function CronPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<ModelCron>[] = [
const columns: ColumnDef<typeof selectableTableFeatures, ModelCron>[] = [
{
id: "select",
header: ({ table }) => (
@@ -56,7 +57,6 @@ export default function CronPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -199,10 +199,10 @@ export default function CronPage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+5 -5
View File
@@ -12,8 +12,9 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { selectableTableFeatures } from "@/lib/table"
import { ModelDDNSProfile } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -45,7 +46,7 @@ export default function DDNSPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<ModelDDNSProfile>[] = [
const columns: ColumnDef<typeof selectableTableFeatures, ModelDDNSProfile>[] = [
{
id: "select",
header: ({ table }) => (
@@ -65,7 +66,6 @@ export default function DDNSPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -136,10 +136,10 @@ export default function DDNSPage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+5 -4
View File
@@ -8,7 +8,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { GitHubIcon } from "@/components/ui/icon"
import { OAuthProviderIcon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { useAuth } from "@/hooks/useAuth"
@@ -39,7 +39,7 @@ function Login() {
if (oauth2) {
loginOauth2()
}
}, [window.location.search])
}, [loginOauth2])
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -56,7 +56,7 @@ function Login() {
async function loginWith(provider: string) {
try {
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.LOGIN)
window.location.href = redirectUrl.redirect!
window.location.assign(redirectUrl.redirect!)
} catch (error: any) {
toast.error(error.message)
}
@@ -120,10 +120,11 @@ function Login() {
<div className="mt-3 flex flex-col gap-3">
{settingData?.config?.oauth2_providers?.map((p: string) => (
<Button
key={p}
className="w-full rounded-lg shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] bg-muted text-primary hover:bg-muted/80 hover:text-primary/80"
onClick={() => loginWith(p)}
>
{p === "GitHub" && <GitHubIcon className="size-4" />}
<OAuthProviderIcon provider={p} className="size-4" />
{p}
</Button>
))}
+5 -5
View File
@@ -12,8 +12,9 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { selectableTableFeatures } from "@/lib/table"
import { ModelNAT } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -33,7 +34,7 @@ export default function NATPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<ModelNAT>[] = [
const columns: ColumnDef<typeof selectableTableFeatures, ModelNAT>[] = [
{
id: "select",
header: ({ table }) => (
@@ -53,7 +54,6 @@ export default function NATPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -119,10 +119,10 @@ export default function NATPage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+10 -5
View File
@@ -13,13 +13,19 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { selectableTableFeatures } from "@/lib/table"
import { ModelNotificationGroupResponseItem } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import useSWR from "swr"
type NotificationGroupColumn = ColumnDef<
typeof selectableTableFeatures,
ModelNotificationGroupResponseItem
>
export default function NotificationGroupPage() {
const { t } = useTranslation()
const { data, mutate, error, isLoading } = useSWR<ModelNotificationGroupResponseItem[]>(
@@ -37,7 +43,7 @@ export default function NotificationGroupPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<ModelNotificationGroupResponseItem>[] = [
const columns: NotificationGroupColumn[] = [
{
id: "select",
header: ({ table }) => (
@@ -57,7 +63,6 @@ export default function NotificationGroupPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -112,10 +117,10 @@ export default function NotificationGroupPage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+5 -5
View File
@@ -15,8 +15,9 @@ import {
TableRow,
} from "@/components/ui/table"
import { useNotification } from "@/hooks/useNotfication"
import { selectableTableFeatures } from "@/lib/table"
import { ModelNotification } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -40,7 +41,7 @@ export default function NotificationPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<ModelNotification>[] = [
const columns: ColumnDef<typeof selectableTableFeatures, ModelNotification>[] = [
{
id: "select",
header: ({ table }) => (
@@ -60,7 +61,6 @@ export default function NotificationPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -127,10 +127,10 @@ export default function NotificationPage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+8 -5
View File
@@ -22,8 +22,12 @@ import {
TableRow,
} from "@/components/ui/table"
import { useAuth } from "@/hooks/useAuth"
import { selectableTableFeatures } from "@/lib/table"
import { GithubComNezhahqNezhaModelPaginatedResponseArrayModelOnlineUserModelOnlineUser, ModelOnlineUser } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
@@ -55,7 +59,7 @@ export default function OnlineUserPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
let columns: ColumnDef<ModelOnlineUser>[] = [
let columns: ColumnDef<typeof selectableTableFeatures, ModelOnlineUser>[] = [
{
id: "select",
header: ({ table }) => (
@@ -75,7 +79,6 @@ export default function OnlineUserPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -128,10 +131,10 @@ export default function OnlineUserPage() {
return data?.data?.value ?? []
}, [data])
const table = useReactTable<ModelOnlineUser>({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+11 -3
View File
@@ -4,6 +4,7 @@ import { ProfileCard } from "@/components/profile"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { OAuthProviderIcon } from "@/components/ui/icon"
import { useMainStore } from "@/hooks/useMainStore"
import { useMediaQuery } from "@/hooks/useMediaQuery"
import { useServer } from "@/hooks/useServer"
@@ -26,12 +27,12 @@ export default function ProfilePage() {
})
window.history.replaceState({}, document.title, window.location.pathname)
}
}, [window.location.search])
}, [setProfile])
const bindO2 = async (provider: string) => {
try {
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.BIND)
window.location.href = redirectUrl.redirect!
window.location.assign(redirectUrl.redirect!)
} catch (error: any) {
toast.error(error.message)
}
@@ -107,8 +108,15 @@ export default function ProfilePage() {
</CardHeader>
<CardContent className="text-lg font-semibold">
{settingData?.config?.oauth2_providers?.map((provider) => (
<div className="flex justify-between items-center flex-wrap gap-2">
<div
key={provider}
className="flex justify-between items-center flex-wrap gap-2"
>
<section className="flex gap-2 items-center">
<OAuthProviderIcon
provider={provider}
className="size-4 text-muted-foreground"
/>
<p>{provider}: </p>
{profile.oauth2_bind?.[provider.toLowerCase()] && (
<p className=" bg-muted px-1.5 py-0.5 text-sm rounded-full">
+29 -9
View File
@@ -1,16 +1,36 @@
import { useAuth } from "@/hooks/useAuth"
import { Navigate } from "react-router-dom"
import { Navigate, useLocation } from "react-router-dom"
const LOGIN_PATH = "/dashboard/login"
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { profile } = useAuth()
const { profile, loading } = useAuth()
const { pathname } = useLocation()
if (!profile && window.location.pathname !== "/dashboard/login") {
return (
<>
<Navigate to="/dashboard/login" />
{children}
</>
)
// While AuthProvider's initial getProfile() round-trip is in flight we
// can't yet decide between "render protected subtree" and "redirect to
// login". Two key invariants:
// - For the login page itself, render children so the user can log in
// without waiting for an unrelated /api/v1/profile probe.
// - For every other /dashboard/* path, do NOT mount children — the
// protected subtree (Root + Outlet + page) would fire authenticated
// SWR fetches like /api/v1/setting before auth is even confirmed,
// and a subsequent redirect would leave that work unobserved. A
// blank render during the (short) probe avoids that wasted round-trip
// and the flash of protected UI before redirect.
if (loading) {
if (pathname === LOGIN_PATH) {
return children
}
return null
}
if (!profile && pathname !== LOGIN_PATH) {
// `replace` keeps the unauthenticated URL out of history. Crucially do
// NOT render `children` alongside Navigate: that would mount the
// protected subtree for one paint and fire authenticated requests we
// are about to redirect away from.
return <Navigate to={LOGIN_PATH} replace />
}
return children
+6 -4
View File
@@ -22,6 +22,12 @@ export default function Root() {
}
}, [settingData?.config?.custom_code_dashboard])
useEffect(() => {
if (settingData?.config?.language && !localStorage.getItem("language")) {
i18n.changeLanguage(settingData?.config?.language)
}
}, [settingData?.config?.language])
if (error) {
throw error
}
@@ -30,10 +36,6 @@ export default function Root() {
return null
}
if (settingData?.config?.language && !localStorage.getItem("language")) {
i18n.changeLanguage(settingData?.config?.language)
}
return (
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<section
+5 -5
View File
@@ -13,8 +13,9 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { selectableTableFeatures } from "@/lib/table"
import { ModelServerGroupResponseItem } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -37,7 +38,7 @@ export default function ServerGroupPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<ModelServerGroupResponseItem>[] = [
const columns: ColumnDef<typeof selectableTableFeatures, ModelServerGroupResponseItem>[] = [
{
id: "select",
header: ({ table }) => (
@@ -57,7 +58,6 @@ export default function ServerGroupPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -112,10 +112,10 @@ export default function ServerGroupPage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+32 -5
View File
@@ -20,9 +20,10 @@ import {
} from "@/components/ui/table"
import { IconButton } from "@/components/xui/icon-button"
import { useServer } from "@/hooks/useServer"
import { selectableTableFeatures } from "@/lib/table"
import { joinIP } from "@/lib/utils"
import { ModelServerTaskResponse, ModelServer as Server } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -43,7 +44,7 @@ export default function ServerPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns = useMemo<ColumnDef<Server>[]>(
const columns = useMemo<ColumnDef<typeof selectableTableFeatures, Server>[]>(
() => [
{
id: "select",
@@ -64,7 +65,6 @@ export default function ServerPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -81,6 +81,33 @@ export default function ServerPage() {
return <div className="max-w-24 whitespace-normal break-words">{s.name}</div>
},
},
{
id: "owner",
header: t("Owner"),
accessorFn: (row) => {
if (!row.owner) return ""
if (row.owner.id === 0) return t("GlobalAgent")
return row.owner.username || t("UnknownUser", { id: row.owner.id })
},
cell: ({ row }) => {
const owner = row.original.owner
if (!owner) {
return <span className="text-muted-foreground">-</span>
}
if (owner.id === 0) {
return <span>{t("GlobalAgent")}</span>
}
const label = owner.username || t("UnknownUser", { id: owner.id })
return (
<div
className="max-w-32 whitespace-normal break-words"
title={`uid=${owner.id}`}
>
{label}
</div>
)
},
},
{
header: t("Group"),
accessorKey: "groups",
@@ -161,10 +188,10 @@ export default function ServerPage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+5 -5
View File
@@ -12,9 +12,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { selectableTableFeatures } from "@/lib/table"
import { ModelService as Service } from "@/types"
import { serviceTypes } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -34,7 +35,7 @@ export default function ServicePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<Service>[] = [
const columns: ColumnDef<typeof selectableTableFeatures, Service>[] = [
{
id: "select",
header: ({ table }) => (
@@ -54,7 +55,6 @@ export default function ServicePage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -165,10 +165,10 @@ export default function ServicePage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+81 -27
View File
@@ -7,6 +7,7 @@ import { Combobox } from "@/components/ui/combobox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -25,13 +26,13 @@ import { Textarea } from "@/components/ui/textarea"
import { useAuth } from "@/hooks/useAuth"
import { useNotification } from "@/hooks/useNotfication"
import useSetting from "@/hooks/useSetting"
import { asOptionalField } from "@/lib/utils"
import { asOptionalField, safeExternalHref } from "@/lib/utils"
import { nezhaLang, settingCoverageTypes } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { Navigate } from "react-router-dom"
import { toast } from "sonner"
import { z } from "zod"
@@ -44,6 +45,8 @@ const settingFormSchema = z.object({
language: z.string().min(2),
user_template: z.string().min(1),
install_host: asOptionalField(z.string()),
dashboard_host: asOptionalField(z.string()),
reserved_hosts: asOptionalField(z.string()),
custom_code: asOptionalField(z.string()),
custom_code_dashboard: asOptionalField(z.string()),
web_real_ip_header: asOptionalField(z.string()),
@@ -53,6 +56,7 @@ const settingFormSchema = z.object({
enable_ip_change_notification: asOptionalField(z.boolean()),
enable_plain_ip_in_notification: asOptionalField(z.boolean()),
custom_logo: asOptionalField(z.string()),
custom_description: asOptionalField(z.string()),
custom_links: asOptionalField(z.string()),
background_image_day: asOptionalField(z.string()),
@@ -66,13 +70,14 @@ const settingFormSchema = z.object({
domain_expiry_notification_days: asOptionalField(z.string()),
server_expiry_notification_days: asOptionalField(z.string()),
expiry_notification_group_id: z.coerce.number().int().min(0),
enable_mcp: asOptionalField(z.boolean()),
})
export default function SettingsPage() {
const { t, i18n } = useTranslation()
const { data: config, mutate } = useSetting()
const { profile } = useAuth()
const navigate = useNavigate()
const { profile, loading: authLoading } = useAuth()
const { notifierGroup } = useNotification()
const ngroupList = notifierGroup?.map((ng) => ({
@@ -82,12 +87,10 @@ export default function SettingsPage() {
const isAdmin = profile?.role === 0
if (!isAdmin) {
navigate("/dashboard/settings/online-user")
}
const form = useForm({
// 所有 hooks 必须在条件 return 之前调用,否则违反 rules-of-hooks。
const form = useForm<z.infer<typeof settingFormSchema>>({
resolver: zodResolver(settingFormSchema) as any,
defaultValues: config
? {
...config.config,
@@ -117,6 +120,13 @@ export default function SettingsPage() {
}
}, [config?.config, form])
if (authLoading) {
return null
}
if (!isAdmin) {
return <Navigate to="/dashboard/settings/api-tokens" replace />
}
const onSubmit = async (values: any) => {
try {
await updateSettings(values)
@@ -129,19 +139,19 @@ export default function SettingsPage() {
}),
})
return
} finally {
}
if (values.language != i18n.language) {
i18n.changeLanguage(values.language)
}
toast(t("Success"))
}
}
return (
<div className="px-3">
<SettingsTab className="mt-6 mb-4 w-full" />
<div>
<Form {...form}>
<Form {...(form as any)}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
<FormField
control={form.control}
@@ -390,14 +400,24 @@ export default function SettingsPage() {
</div>
</SelectItem>
<div className="px-8 py-1">
{safeExternalHref(
template.repository,
) ? (
<a
href={template.repository}
href={safeExternalHref(
template.repository,
)}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{template.repository}
</a>
) : (
<span className="text-sm text-muted-foreground">
{template.repository}
</span>
)}
</div>
</div>
))}
@@ -459,6 +479,34 @@ export default function SettingsPage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="dashboard_host"
render={({ field }) => (
<FormItem>
<FormLabel>{t("DashboardHost")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>{t("DashboardHostHint")}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reserved_hosts"
render={({ field }) => (
<FormItem>
<FormLabel>{t("ReservedHosts")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>{t("ReservedHostsHint")}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tls"
@@ -512,16 +560,10 @@ export default function SettingsPage() {
checked={field.value == "NZ::Use-Peer-IP"}
className="ml-2"
onCheckedChange={(checked) => {
if (checked) {
field.disabled = true
form.setValue(
"web_real_ip_header",
"NZ::Use-Peer-IP",
checked ? "NZ::Use-Peer-IP" : "",
)
} else {
field.disabled = false
form.setValue("web_real_ip_header", "")
}
}}
/>
<FormLabel className="font-normal ml-2">
@@ -551,16 +593,10 @@ export default function SettingsPage() {
checked={field.value == "NZ::Use-Peer-IP"}
className="ml-2"
onCheckedChange={(checked) => {
if (checked) {
field.disabled = true
form.setValue(
"agent_real_ip_header",
"NZ::Use-Peer-IP",
checked ? "NZ::Use-Peer-IP" : "",
)
} else {
field.disabled = false
form.setValue("agent_real_ip_header", "")
}
}}
/>
<FormLabel className="font-normal ml-2">
@@ -749,6 +785,24 @@ export default function SettingsPage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_mcp"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">{t("EnableMCP")}</Label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">{t("Confirm")}</Button>
</form>
</Form>
+257
View File
@@ -0,0 +1,257 @@
import { swrFetcher } from "@/api/api"
import { cancelServerTransfer, retryServerTransfer } from "@/api/transfer"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { useAuth } from "@/hooks/useAuth"
import {
ModelServerTransfer,
ModelServerTransferStatus,
ModelServerTransferStatusCancelled,
ModelServerTransferStatusFailed,
ModelServerTransferStatusPending,
ModelServerTransferStatusTimeout,
ModelServerTransferStatusVerified,
} from "@/types"
import { useCallback, useEffect, useMemo, useRef } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import useSWR from "swr"
// Map status enum to a label key + a Badge variant. Centralised here so the
// table cell and any future drawer agree. Verified is "secondary" because
// the row is no longer actionable; Failed/Timeout use "destructive" to draw
// the operator's attention. Cancelled is "outline" — soft signal, expected.
const statusMeta: Record<
ModelServerTransferStatus,
{ key: string; variant: "default" | "secondary" | "destructive" | "outline" }
> = {
[ModelServerTransferStatusPending]: { key: "Transfer.StatusPending", variant: "default" },
[ModelServerTransferStatusVerified]: { key: "Transfer.StatusVerified", variant: "secondary" },
[ModelServerTransferStatusFailed]: { key: "Transfer.StatusFailed", variant: "destructive" },
[ModelServerTransferStatusTimeout]: { key: "Transfer.StatusTimeout", variant: "destructive" },
[ModelServerTransferStatusCancelled]: { key: "Transfer.StatusCancelled", variant: "outline" },
}
export default function TransferPage() {
const { t } = useTranslation()
const { profile } = useAuth()
const isAdmin = profile?.role === 0
const callerID = profile?.id
const { data, mutate, error } = useSWR<ModelServerTransfer[]>("/api/v1/transfer", swrFetcher)
useEffect(() => {
if (error) {
toast(t("Error"), {
description: t("Results.UnExpectedError", { error: error.message }),
})
}
}, [error, t])
// Live updates: subscribe to /ws/transfer and patch the SWR cache on each
// pushed event. We don't refetch on every event because the WS payload IS
// the latest row — refetching would just cost a round trip.
//
// Reconnect contract: the original implementation opened the socket once
// and never reconnected, so a dashboard restart, deploy, or transient
// network blip silently froze this view — rows stayed Pending forever
// even after the agent had reconnected and the backend had transitioned
// them, and operators only discovered it on a hard refresh. Reconnect
// with capped exponential backoff (1s → 30s) and trigger a SWR revalidate
// on each open so any transitions that broadcast while we were offline
// are reconciled.
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
let cancelled = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let backoffMs = 1000
const maxBackoffMs = 30000
const connect = () => {
if (cancelled) return
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"
const ws = new WebSocket(`${proto}//${window.location.host}/api/v1/ws/transfer`)
wsRef.current = ws
ws.onopen = () => {
backoffMs = 1000
// Backfill anything the broker fanned out while we were
// reconnecting — the broker drops events for full subscribers
// and there is no replay on the wire.
mutate()
}
ws.onmessage = (ev) => {
try {
const t: ModelServerTransfer = JSON.parse(ev.data)
mutate((prev) => {
const list = prev ?? []
const idx = list.findIndex((x) => x.id === t.id)
if (idx === -1) return [t, ...list]
const next = list.slice()
next[idx] = t
return next
}, false)
} catch (e) {
// Malformed payload — log and keep the socket open; a refetch
// on next mount will re-sync. Closing the WS would be louder
// than the actual problem.
console.error("transfer ws parse failed", e)
}
}
const scheduleReconnect = () => {
if (cancelled) return
if (reconnectTimer !== null) return
const delay = backoffMs
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, delay)
}
ws.onclose = scheduleReconnect
ws.onerror = () => {
// onerror fires before onclose and may not produce a close
// event in every browser when the handshake is rejected.
// Force-close so onclose's reconnect path runs deterministically.
ws.close()
}
}
connect()
return () => {
cancelled = true
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const ws = wsRef.current
wsRef.current = null
if (ws) {
// Detach handlers so the close we trigger here doesn't bounce
// into a reconnect attempt against an unmounted component.
ws.onopen = null
ws.onmessage = null
ws.onclose = null
ws.onerror = null
ws.close()
}
}
}, [mutate])
const onCancel = useCallback(
async (id: number) => {
try {
await cancelServerTransfer(id)
toast(t("Transfer.CancelRequested"))
mutate()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
toast(t("Error"), { description: msg })
}
},
[mutate, t],
)
const onRetry = useCallback(
async (id: number) => {
try {
await retryServerTransfer(id)
toast(t("Transfer.RetryRequested"))
mutate()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
toast(t("Error"), { description: msg })
}
},
[mutate, t],
)
const rows = useMemo(() => data ?? [], [data])
return (
<div className="px-3">
<h1 className="mt-6 text-2xl font-semibold">{t("Transfer.Title")}</h1>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">{t("Transfer.PageHint")}</p>
<Table className="mt-6">
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t("ServerID")}</TableHead>
<TableHead>{t("Transfer.From")}</TableHead>
<TableHead>{t("Transfer.To")}</TableHead>
<TableHead>{t("Transfer.Initiator")}</TableHead>
<TableHead>{t("Status")}</TableHead>
<TableHead>{t("Transfer.LastError")}</TableHead>
<TableHead>{t("CreatedAt")}</TableHead>
<TableHead>{t("Actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center">
{t("NoResults")}
</TableCell>
</TableRow>
) : (
rows.map((row) => {
const meta = statusMeta[row.status]
const isPending = row.status === ModelServerTransferStatusPending
const isTerminalRevert =
row.status === ModelServerTransferStatusFailed ||
row.status === ModelServerTransferStatusTimeout ||
row.status === ModelServerTransferStatusCancelled
return (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell>{row.server_id}</TableCell>
<TableCell>{row.from_user_id}</TableCell>
<TableCell>{row.to_user_id}</TableCell>
<TableCell>{row.initiator_id}</TableCell>
<TableCell>
<Badge variant={meta.variant}>{t(meta.key)}</Badge>
</TableCell>
<TableCell className="max-w-xs truncate" title={row.last_error}>
{row.last_error || "-"}
</TableCell>
<TableCell>
{new Date(row.created_at).toLocaleString()}
</TableCell>
<TableCell className="space-x-2">
{isPending &&
(isAdmin || row.from_user_id === callerID) && (
<Button
size="sm"
variant="outline"
onClick={() => onCancel(row.id)}
>
{t("Cancel")}
</Button>
)}
{isTerminalRevert && isAdmin && (
<Button
size="sm"
variant="outline"
onClick={() => onRetry(row.id)}
>
{t("Transfer.Retry")}
</Button>
)}
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
)
}
+5 -5
View File
@@ -13,8 +13,9 @@ import {
TableRow,
} from "@/components/ui/table"
import { UserCard } from "@/components/user"
import { selectableTableFeatures } from "@/lib/table"
import { ModelUser } from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -34,7 +35,7 @@ export default function UserPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
const columns: ColumnDef<ModelUser>[] = [
const columns: ColumnDef<typeof selectableTableFeatures, ModelUser>[] = [
{
id: "select",
header: ({ table }) => (
@@ -54,7 +55,6 @@ export default function UserPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -105,10 +105,10 @@ export default function UserPage() {
return data ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+5 -5
View File
@@ -22,13 +22,14 @@ import {
TableRow,
} from "@/components/ui/table"
import { useAuth } from "@/hooks/useAuth"
import { selectableTableFeatures } from "@/lib/table"
import {
GithubComNezhahqNezhaModelValueArrayModelWAFApiMock,
ModelWAFApiMock,
wafBlockIdentifiers,
wafBlockReasons,
} from "@/types"
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
@@ -61,7 +62,7 @@ export default function WAFPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
let columns: ColumnDef<ModelWAFApiMock>[] = [
let columns: ColumnDef<typeof selectableTableFeatures, ModelWAFApiMock>[] = [
{
id: "select",
header: ({ table }) => (
@@ -81,7 +82,6 @@ export default function WAFPage() {
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
@@ -147,10 +147,10 @@ export default function WAFPage() {
return data?.value ?? []
}, [data])
const table = useReactTable({
const table = useTable({
features: selectableTableFeatures,
data: dataCache,
columns,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "vitest"
import { parseExpiresInDaysInput } from "../api/api-tokens"
test("parseExpiresInDaysInput treats blank as 'never expires' (undefined)", () => {
expect(parseExpiresInDaysInput("")).toEqual({ ok: true, value: undefined })
expect(parseExpiresInDaysInput(" ")).toEqual({ ok: true, value: undefined })
})
test("parseExpiresInDaysInput accepts whole-number days in range", () => {
expect(parseExpiresInDaysInput("30")).toEqual({ ok: true, value: 30 })
expect(parseExpiresInDaysInput(" 3650 ")).toEqual({ ok: true, value: 3650 })
})
test("parseExpiresInDaysInput maps 0 to undefined (never expires)", () => {
expect(parseExpiresInDaysInput("0")).toEqual({ ok: true, value: undefined })
})
// Backend model field is `ExpiresInDays int` (nezha/model/api_token.go); a
// fractional value would fail JSON binding server-side, so the UI must reject
// it locally rather than send 1.5 and surface a confusing backend error.
test("parseExpiresInDaysInput rejects fractional days", () => {
expect(parseExpiresInDaysInput("1.5").ok).toBe(false)
})
test("parseExpiresInDaysInput rejects out-of-range and non-numeric input", () => {
expect(parseExpiresInDaysInput("-1").ok).toBe(false)
expect(parseExpiresInDaysInput("3651").ok).toBe(false)
expect(parseExpiresInDaysInput("abc").ok).toBe(false)
})
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "vitest"
import { parseServerIDsInput } from "../api/api-tokens"
test("parseServerIDsInput returns undefined for empty input", () => {
expect(parseServerIDsInput("")).toEqual({ ok: true, value: undefined })
expect(parseServerIDsInput(" ")).toEqual({ ok: true, value: undefined })
})
test("parseServerIDsInput parses valid comma-separated positive ints", () => {
expect(parseServerIDsInput("1,2,3")).toEqual({ ok: true, value: [1, 2, 3] })
expect(parseServerIDsInput(" 10 , 11 ")).toEqual({ ok: true, value: [10, 11] })
})
test("parseServerIDsInput rejects any non-numeric token rather than silently dropping it", () => {
// 历史 UI 把 "1,abc" 静默裁剪为 [1] 再上送,等价于把"输入完整接受"骗给用户。
// 这条契约要求:任一片段非法 → 整次解析失败,前端必须报错而不是吞掉。
const r = parseServerIDsInput("1,abc")
expect(r.ok).toBe(false)
})
test("parseServerIDsInput rejects non-positive ids", () => {
expect(parseServerIDsInput("0").ok).toBe(false)
expect(parseServerIDsInput("-3").ok).toBe(false)
expect(parseServerIDsInput("1.5").ok).toBe(false)
})
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { createApiToken, deleteApiToken, listApiTokens } from "../api/api-tokens"
const realFetch = globalThis.fetch
function mockFetch(payload: unknown, ok = true, success = true) {
globalThis.fetch = vi.fn(async () => {
return new Response(
JSON.stringify({ success, error: success ? "" : "boom", data: payload }),
{
status: ok ? 200 : 500,
headers: { "Content-Type": "application/json" },
},
)
}) as unknown as typeof fetch
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
globalThis.fetch = realFetch
vi.restoreAllMocks()
})
test("listApiTokens GETs /api/v1/api-tokens and returns parsed array", async () => {
const calls: Array<{ url: string; method: string }> = []
globalThis.fetch = vi.fn(async (input: any, init?: any) => {
calls.push({ url: String(input), method: String(init?.method ?? "GET") })
return new Response(
JSON.stringify({
success: true,
data: [
{
id: 1,
name: "claude",
scopes: ["nezha:server:read"],
created_at: "2025-01-01T00:00:00Z",
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}) as unknown as typeof fetch
const got = await listApiTokens()
expect(calls).toHaveLength(1)
expect(calls[0].method).toBe("GET")
expect(calls[0].url).toContain("/api/v1/api-tokens")
expect(got).toHaveLength(1)
expect(got[0].name).toBe("claude")
expect(got[0].scopes).toContain("nezha:server:read")
})
test("createApiToken POSTs /api/v1/api-tokens and serializes scopes / server_ids / expires_in_days", async () => {
let captured: { url: string; method: string; body: any } | null = null
globalThis.fetch = vi.fn(async (input: any, init?: any) => {
captured = {
url: String(input),
method: String(init?.method ?? ""),
body: init?.body ? JSON.parse(init.body as string) : null,
}
return new Response(
JSON.stringify({
success: true,
data: {
id: 2,
name: "x",
token: "nzp_FAKEABC",
scopes: ["nezha:server:read", "nezha:server:write"],
server_ids: [10, 11],
expires_at: "2026-01-01T00:00:00Z",
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}) as unknown as typeof fetch
const res = await createApiToken({
name: "x",
scopes: ["nezha:server:read", "nezha:server:write"],
server_ids: [10, 11],
expires_in_days: 30,
})
expect(res.token).toBe("nzp_FAKEABC")
expect(captured).not.toBeNull()
expect(captured!.method).toBe("POST")
expect(captured!.url).toContain("/api/v1/api-tokens")
expect(captured!.body.name).toBe("x")
expect(captured!.body.scopes).toEqual(["nezha:server:read", "nezha:server:write"])
expect(captured!.body.server_ids).toEqual([10, 11])
expect(captured!.body.expires_in_days).toBe(30)
})
test("createApiToken surfaces server error via thrown Error", async () => {
mockFetch(null, true, false)
await expect(createApiToken({ name: "x", scopes: ["nezha:server:read"] })).rejects.toThrow(
"boom",
)
})
test("listApiTokens normalizes null scopes to an empty array so the table cannot crash", async () => {
// Backend APIToken.Scopes() returns nil for ScopesCSV=="" which JSON-encodes
// as null; migrated/legacy/hand-edited rows hit this. Without normalization
// the list page does tok.scopes.map(...) on null and the whole page crashes.
globalThis.fetch = vi.fn(async () => {
return new Response(
JSON.stringify({
success: true,
data: [{ id: 1, name: "legacy", scopes: null, created_at: "2025-01-01T00:00:00Z" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}) as unknown as typeof fetch
const got = await listApiTokens()
expect(Array.isArray(got[0].scopes)).toBe(true)
expect(got[0].scopes).toEqual([])
})
test("deleteApiToken DELETEs /api/v1/api-tokens/:id", async () => {
const calls: Array<{ url: string; method: string }> = []
globalThis.fetch = vi.fn(async (input: any, init?: any) => {
calls.push({ url: String(input), method: String(init?.method ?? "GET") })
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}) as unknown as typeof fetch
await deleteApiToken(42)
expect(calls).toHaveLength(1)
expect(calls[0].method).toBe("DELETE")
expect(calls[0].url).toContain("/api/v1/api-tokens/42")
})
+150
View File
@@ -0,0 +1,150 @@
import { act, render } from "@testing-library/react"
import { useEffect } from "react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
let profileStore: { id: number; role: number } | undefined
const setProfileSpy = vi.fn((p: any) => {
profileStore = p
})
vi.mock("./useMainStore", () => ({}))
vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector: any) => selector({ profile: profileStore, setProfile: setProfileSpy }),
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}))
vi.mock("sonner", () => ({ toast: () => {} }))
const navigate = vi.fn()
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
}))
// Initial getProfile() stays pending forever so loading can only be cleared
// by an explicit login/logout, which is exactly what these tests assert.
let initialProfilePromise: Promise<any>
let getProfileCall = 0
const loginRequest = vi.fn(async () => {})
vi.mock("@/api/user", () => ({
getProfile: vi.fn(() => {
getProfileCall++
if (getProfileCall === 1) return initialProfilePromise
return Promise.resolve({ id: 42, role: 0 })
}),
login: () => loginRequest(),
}))
beforeEach(() => {
profileStore = undefined
getProfileCall = 0
initialProfilePromise = new Promise<any>(() => {})
setProfileSpy.mockClear()
navigate.mockClear()
})
afterEach(() => {
document.body.innerHTML = ""
vi.clearAllMocks()
})
// AuthProvider starts with loading=true and only clears it in the initial
// mount probe's finally{}. A user can log in while that probe is still in
// flight (ProtectedRoute renders the login page during loading). If login()
// does not clear loading itself, ProtectedRoute keeps returning null for
// /dashboard and the freshly-authenticated user sees a blank screen until the
// unrelated probe settles.
test("login() clears loading even while the initial profile probe is still pending", async () => {
const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
const captured: { auth?: ReturnType<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
// Initial probe still pending -> loading must be true.
expect(captured.auth!.loading).toBe(true)
await act(async () => {
await captured.auth!.login("u", "p")
})
// Login succeeded; loading must be false so ProtectedRoute renders.
expect(profileStore).toEqual({ id: 42, role: 0 })
expect(captured.auth!.loading).toBe(false)
})
test("loginOauth2() clears loading even while the initial profile probe is still pending", async () => {
const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
const captured: { auth?: ReturnType<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
expect(captured.auth!.loading).toBe(true)
await act(async () => {
await captured.auth!.loginOauth2()
})
expect(captured.auth!.loading).toBe(false)
})
test("logout() clears loading even while the initial profile probe is still pending", async () => {
const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
const captured: { auth?: ReturnType<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
expect(captured.auth!.loading).toBe(true)
await act(async () => {
captured.auth!.logout()
})
expect(captured.auth!.loading).toBe(false)
})
+95
View File
@@ -0,0 +1,95 @@
import { act, render } from "@testing-library/react"
import { useEffect } from "react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
let profileStore: { id: number; role: number } | undefined
const setProfileSpy = vi.fn((p: any) => {
profileStore = p
})
vi.mock("./useMainStore", () => ({}))
vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector: any) => selector({ profile: profileStore, setProfile: setProfileSpy }),
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}))
vi.mock("sonner", () => ({ toast: () => {} }))
const navigate = vi.fn()
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
}))
// Deferred initial getProfile() so we can resolve/reject it AFTER login().
let rejectInitial: (e: any) => void
const initialProfilePromise = new Promise((_res, rej) => {
rejectInitial = rej
})
let getProfileCall = 0
const loginRequest = vi.fn(async () => {})
vi.mock("@/api/user", () => ({
getProfile: vi.fn(() => {
getProfileCall++
if (getProfileCall === 1) return initialProfilePromise
return Promise.resolve({ id: 42, role: 0 })
}),
login: () => loginRequest(),
}))
beforeEach(() => {
profileStore = undefined
getProfileCall = 0
setProfileSpy.mockClear()
navigate.mockClear()
})
afterEach(() => {
document.body.innerHTML = ""
vi.clearAllMocks()
})
// The AuthProvider fires getProfile() on mount. While that probe is in flight a
// user can submit the login form (ProtectedRoute renders the login page during
// loading). If the in-flight probe later REJECTS (e.g. it 401'd because the
// user was not yet authenticated), its catch{} must NOT clobber the profile a
// successful login() already set — otherwise the freshly-authenticated user is
// bounced back to the login page.
test("late-rejecting initial profile probe does not clobber a successful login", async () => {
const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
const captured: { auth?: ReturnType<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
// User logs in while the initial probe is still pending.
await act(async () => {
await captured.auth!.login("u", "p")
})
expect(profileStore).toEqual({ id: 42, role: 0 })
// Now the stale initial probe rejects (it was a pre-auth 401).
await act(async () => {
rejectInitial(new Error("401"))
await initialProfilePromise.catch(() => {})
})
// The logged-in profile must survive.
expect(profileStore).toEqual({ id: 42, role: 0 })
expect(setProfileSpy).not.toHaveBeenLastCalledWith(undefined)
})
+87
View File
@@ -0,0 +1,87 @@
import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const toastCalls: Array<{ title: string; description: string }> = []
vi.mock("sonner", () => ({
toast: (title: string, opts?: { description?: string }) => {
toastCalls.push({ title, description: opts?.description ?? "" })
},
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) => {
if (vars && typeof vars.count === "number") {
return `${key}=${vars.count}`
}
return key
},
}),
initReactI18next: { type: "3rdParty", init: () => undefined },
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
}))
const batchMoveServer = vi.fn()
vi.mock("@/api/server", () => ({
batchMoveServer: (...args: unknown[]) => batchMoveServer(...args),
}))
beforeEach(() => {
toastCalls.length = 0
batchMoveServer.mockReset()
})
afterEach(() => {
document.body.innerHTML = ""
})
async function openDialogAndSubmit(serverIds: number[], toUser: number) {
render(<BatchMoveServerIcon serverIds={serverIds} />)
await act(async () => {
fireEvent.click(screen.getByRole("button"))
})
const userInput = await screen.findByPlaceholderText("User ID")
await act(async () => {
fireEvent.change(userInput, { target: { value: String(toUser) } })
})
const submit = screen.getByRole("button", { name: "Move" })
await act(async () => {
fireEvent.click(submit)
})
await waitFor(() => expect(toastCalls.length).toBeGreaterThan(0))
}
test("BatchMoveServer toast surfaces agent_too_old count so operator sees the failure", async () => {
batchMoveServer.mockResolvedValueOnce([
{ server_id: 1, status: "agent_too_old", error: "agent build older than v1.18.0" },
{ server_id: 2, status: "pending", transfer_id: 99 },
])
await openDialogAndSubmit([1, 2], 300)
expect(batchMoveServer).toHaveBeenCalledOnce()
const summary = toastCalls[toastCalls.length - 1]
expect(summary, "submission must surface a toast").toBeDefined()
expect(summary.description, "toast must include the agent_too_old count").toContain(
"Transfer.AgentTooOldCount=1",
)
expect(summary.description).toContain("Transfer.PendingCount=1")
})
test("BatchMoveServer toast does not show Done fallback when every server is agent_too_old", async () => {
batchMoveServer.mockResolvedValueOnce([
{ server_id: 1, status: "agent_too_old", error: "older than v1.18.0" },
{ server_id: 2, status: "agent_too_old", error: "older than v1.18.0" },
])
await openDialogAndSubmit([1, 2], 300)
const summary = toastCalls[toastCalls.length - 1]
expect(summary).toBeDefined()
expect(
summary.description,
"all-failed batch must NOT collapse to a generic Done label — operator would think it succeeded",
).toContain("Transfer.AgentTooOldCount=2")
expect(summary.description).not.toBe("Done")
})
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FetcherMethod, fetcher } from "../api/api"
const realFetch = globalThis.fetch
function setCookie(value: string) {
Object.defineProperty(document, "cookie", { value, configurable: true })
}
beforeEach(() => {
setCookie("")
})
afterEach(() => {
globalThis.fetch = realFetch
vi.restoreAllMocks()
})
function jsonOk() {
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}
function headerOf(init: RequestInit | undefined, name: string): string | null {
const h = init?.headers
if (!h) return null
if (h instanceof Headers) return h.get(name)
const rec = h as Record<string, string>
const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase())
return key ? rec[key] : null
}
// Backend csrfMiddleware (nezha/cmd/dashboard/controller/csrf.go) enforces a
// double-submit cookie on every cookie-auth unsafe method: it rejects the
// request unless X-CSRF-Token header == nz-csrf cookie. The fetcher must mirror
// the cookie into the header for POST/PATCH/PUT/DELETE.
test("POST sends X-CSRF-Token mirrored from nz-csrf cookie", async () => {
setCookie("nz-csrf=abc123; other=1")
const seen: { init?: RequestInit }[] = []
globalThis.fetch = vi.fn(async (_input: any, init?: RequestInit) => {
seen.push({ init })
return jsonOk()
}) as unknown as typeof fetch
await fetcher(FetcherMethod.POST, "/api/v1/api-tokens", { name: "x" })
expect(headerOf(seen[0].init, "X-CSRF-Token")).toBe("abc123")
})
test("DELETE sends X-CSRF-Token mirrored from nz-csrf cookie", async () => {
setCookie("nz-csrf=del-token")
const seen: { init?: RequestInit }[] = []
globalThis.fetch = vi.fn(async (_input: any, init?: RequestInit) => {
seen.push({ init })
return jsonOk()
}) as unknown as typeof fetch
await fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/7")
expect(headerOf(seen[0].init, "X-CSRF-Token")).toBe("del-token")
})
test("auto refresh-token uses POST (backend route is POST)", async () => {
vi.resetModules()
const { FetcherMethod: M, fetcher: f } = await import("../api/api")
setCookie("nz-jwt=sess; nz-csrf=c")
const seen: { url: string; init?: RequestInit }[] = []
globalThis.fetch = vi.fn(async (input: any, init?: RequestInit) => {
seen.push({ url: String(input), init })
return jsonOk()
}) as unknown as typeof fetch
await f(M.GET, "/api/v1/server")
const refresh = seen.find((s) => s.url.includes("/api/v1/refresh-token"))
expect(refresh, "auto refresh request should be issued").toBeTruthy()
expect(refresh!.init?.method).toBe("POST")
})
// Revoke (DELETE) commonly returns 204 / empty body. The fetcher must not
// blow up on response.json() of an empty body and must resolve successfully.
test("DELETE tolerates an empty 204 response body", async () => {
globalThis.fetch = vi.fn(
async () => new Response(null, { status: 204 }),
) as unknown as typeof fetch
await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined()
})
test("empty 200 body does not throw", async () => {
globalThis.fetch = vi.fn(
async () => new Response("", { status: 200 }),
) as unknown as typeof fetch
await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined()
})
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FetcherMethod, fetcher } from "../api/api"
const realFetch = globalThis.fetch
beforeEach(() => {
// Avoid the auto refresh-token branch interfering with assertions.
Object.defineProperty(document, "cookie", { value: "", configurable: true })
})
afterEach(() => {
globalThis.fetch = realFetch
vi.restoreAllMocks()
})
// Regression: fetcher used to collapse GET and DELETE into the same HTTP GET,
// so DELETE callers (e.g. revoke API token) never actually hit the backend
// DELETE route. The wire-level method must match the requested FetcherMethod.
test("fetcher uses HTTP DELETE for FetcherMethod.DELETE", async () => {
const seen: { url: string; init?: RequestInit }[] = []
globalThis.fetch = vi.fn(async (input: any, init?: RequestInit) => {
seen.push({ url: String(input), init })
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}) as unknown as typeof fetch
await fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/42")
expect(seen).toHaveLength(1)
expect(seen[0].init?.method).toBe("DELETE")
expect(seen[0].url).toContain("/api/v1/api-tokens/42")
})
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const realFetch = globalThis.fetch
function setCookie(value: string) {
Object.defineProperty(document, "cookie", { value, configurable: true })
}
function jsonOk() {
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}
beforeEach(() => {
setCookie("")
vi.resetModules()
})
afterEach(() => {
globalThis.fetch = realFetch
vi.restoreAllMocks()
})
// Auto-refresh is a POST behind the CSRF gate. When the session still lacks the
// nz-csrf cookie (e.g. just upgraded), firing the refresh without a header only
// burns the 1h throttle window and 403s. The refresh must instead wait until a
// CSRF token is available so it can actually succeed once the cookie is seeded.
test("auto refresh is deferred while nz-csrf cookie is missing", async () => {
const { FetcherMethod, fetcher } = await import("../api/api")
setCookie("nz-jwt=session") // jwt present, but no nz-csrf yet
const urls: string[] = []
globalThis.fetch = vi.fn(async (input: any) => {
urls.push(String(input))
return jsonOk()
}) as unknown as typeof fetch
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false)
})
// Once the cookie exists, the next GET must be allowed to fire the refresh —
// proving the missing-cookie skip did not permanently consume the throttle.
test("auto refresh fires after nz-csrf cookie becomes available", async () => {
const { FetcherMethod, fetcher } = await import("../api/api")
const urls: string[] = []
globalThis.fetch = vi.fn(async (input: any) => {
urls.push(String(input))
return jsonOk()
}) as unknown as typeof fetch
setCookie("nz-jwt=session") // first GET: no csrf, refresh skipped
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false)
setCookie("nz-jwt=session; nz-csrf=seeded") // backend seeded it
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(true)
})
+249
View File
@@ -0,0 +1,249 @@
import { act, render, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FMComponent } from "../components/fm"
type DivProps = React.ComponentPropsWithoutRef<"div"> & { asChild?: boolean }
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
asChild?: boolean
size?: string
variant?: string
}
type IconButtonProps = ButtonProps & { icon?: string }
type SentWebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView
vi.mock("../components/ui/button", () => ({
Button: (props: ButtonProps) => {
const { asChild, size, variant, ...buttonProps } = props
void asChild
void size
void variant
return <button {...buttonProps} />
},
}))
vi.mock("../components/ui/input", () => ({
Input: (props: React.ComponentPropsWithoutRef<"input">) => <input {...props} />,
}))
vi.mock("../components/ui/table", () => ({
Table: (props: React.ComponentPropsWithoutRef<"table">) => <table {...props} />,
TableHeader: (props: React.ComponentPropsWithoutRef<"thead">) => <thead {...props} />,
TableBody: (props: React.ComponentPropsWithoutRef<"tbody">) => <tbody {...props} />,
TableRow: (props: React.ComponentPropsWithoutRef<"tr">) => <tr {...props} />,
TableCell: (props: React.ComponentPropsWithoutRef<"td">) => <td {...props} />,
TableHead: (props: React.ComponentPropsWithoutRef<"th">) => <th {...props} />,
}))
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: (props: DivProps) => <div {...props} />,
DropdownMenuTrigger: (props: DivProps) => <div {...props} />,
DropdownMenuContent: (props: DivProps) => <div {...props} />,
DropdownMenuItem: (props: DivProps) => <div {...props} />,
}))
vi.mock("../components/ui/alert-dialog", () => ({
AlertDialog: (props: DivProps) => <div {...props} />,
AlertDialogTrigger: (props: DivProps) => <div {...props} />,
AlertDialogContent: (props: DivProps) => <div {...props} />,
AlertDialogHeader: (props: DivProps) => <div {...props} />,
AlertDialogFooter: (props: DivProps) => <div {...props} />,
AlertDialogTitle: (props: DivProps) => <div {...props} />,
AlertDialogDescription: (props: DivProps) => <div {...props} />,
AlertDialogCancel: (props: DivProps) => <div {...props} />,
AlertDialogAction: (props: DivProps) => <div {...props} />,
}))
vi.mock("../components/ui/drawer", () => ({
Drawer: (props: DivProps) => <div {...props} />,
DrawerContent: (props: DivProps) => <div {...props} />,
DrawerHeader: (props: DivProps) => <div {...props} />,
DrawerTitle: (props: DivProps) => <div {...props} />,
DrawerTrigger: (props: DivProps) => <div {...props} />,
}))
vi.mock("../components/xui/overlayless-sheet", () => ({
Sheet: (props: DivProps) => <div {...props} />,
SheetContent: (props: DivProps) => <div {...props} />,
SheetDescription: (props: DivProps) => <div {...props} />,
SheetHeader: (props: DivProps) => <div {...props} />,
SheetTitle: (props: DivProps) => <div {...props} />,
SheetTrigger: (props: DivProps) => <div {...props} />,
}))
vi.mock("../components/xui/filepath", () => ({
Filepath: () => <div data-testid="filepath" />,
}))
vi.mock("../components/xui/icon-button", () => ({
IconButton: (props: IconButtonProps) => {
const { asChild, icon, size, variant, ...buttonProps } = props
void asChild
void icon
void size
void variant
return <button {...buttonProps} />
},
}))
vi.mock("../components/xui/virtulized-data-table", () => ({
DataTable: () => <div data-testid="data-table" />,
}))
vi.mock("lucide-react", () => ({
File: () => <div data-testid="file-icon" />,
Folder: () => <div data-testid="folder-icon" />,
}))
const translate = (key: string) => key
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: translate }),
initReactI18next: { type: "3rdParty", init: () => {} },
}))
vi.mock("sonner", () => ({ toast: vi.fn() }))
vi.mock("@/lib/utils", () => ({
copyToClipboard: vi.fn(),
fm: {
parseFMList: async (buf: ArrayBufferLike) => {
const view = new DataView(buf)
const pathLength = view.getUint32(4, false)
const pathBytes = new Uint8Array(buf, 8, pathLength)
return { path: new TextDecoder().decode(pathBytes), fmList: [] }
},
},
fmWorker: new Worker(""),
formatPath: (path: string) => path,
}))
const webSockets: MockWebSocket[] = []
class MockWebSocket extends EventTarget implements WebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSING = 2
static readonly CLOSED = 3
readonly CONNECTING = MockWebSocket.CONNECTING
readonly OPEN = MockWebSocket.OPEN
readonly CLOSING = MockWebSocket.CLOSING
readonly CLOSED = MockWebSocket.CLOSED
readonly bufferedAmount = 0
readonly extensions = ""
readonly protocol = ""
readonly url: string
binaryType: BinaryType = "arraybuffer"
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null
readyState: 0 | 1 | 2 | 3 = MockWebSocket.CONNECTING
sent: SentWebSocketData[] = []
constructor(url: string | URL) {
super()
this.url = url.toString()
webSockets.push(this)
}
addEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
options?: boolean | AddEventListenerOptions,
): void
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener, options)
}
removeEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
options?: boolean | EventListenerOptions,
): void
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | EventListenerOptions,
): void {
super.removeEventListener(type, listener, options)
}
close(): void {
this.readyState = MockWebSocket.CLOSED
}
open(): void {
this.readyState = MockWebSocket.OPEN
this.onopen?.call(this, new Event("open"))
}
send(data: SentWebSocketData): void {
this.sent.push(data)
}
}
beforeEach(() => {
webSockets.length = 0
globalThis.WebSocket = MockWebSocket
})
afterEach(() => {
vi.clearAllMocks()
})
const encodeFileNameMessage = (path: string) => {
const identifier = new Uint8Array([0x4e, 0x5a, 0x46, 0x4e])
const pathBytes = new TextEncoder().encode(path)
const payload = new Uint8Array(identifier.length + 4 + pathBytes.length)
payload.set(identifier, 0)
new DataView(payload.buffer).setUint32(identifier.length, pathBytes.length, false)
payload.set(pathBytes, identifier.length + 4)
return payload.buffer
}
const encodeCompleteMessage = () => new Uint8Array([0x4e, 0x5a, 0x55, 0x50]).buffer
const decodeListPath = (data: SentWebSocketData) => {
if (!ArrayBuffer.isView(data)) return null
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
if (bytes[0] !== 0) return null
return new TextDecoder().decode(bytes.slice(1))
}
test("FM websocket lifecycle reuses the socket on path changes", async () => {
const { unmount } = render(<FMComponent wsUrl="/ws/file/test" />)
await waitFor(() => {
expect(webSockets).toHaveLength(1)
})
const socket = webSockets[0]
act(() => {
socket.open()
})
await act(async () => {
await socket.onmessage?.call(
socket,
new MessageEvent("message", { data: encodeFileNameMessage("/new/path") }),
)
})
await waitFor(() => {
expect(webSockets).toHaveLength(1)
expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
})
socket.sent.length = 0
await act(async () => {
await socket.onmessage?.call(
socket,
new MessageEvent("message", { data: encodeCompleteMessage() }),
)
})
expect(webSockets).toHaveLength(1)
expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
unmount()
expect(socket.readyState).toBe(MockWebSocket.CLOSED)
})
+95
View File
@@ -0,0 +1,95 @@
import { act, render } from "@testing-library/react"
import { MemoryRouter, Route, Routes } from "react-router-dom"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
// ProtectedRoute must NOT mount its children while AuthProvider is still
// running the initial getProfile() probe — except for the /dashboard/login
// path itself. Mounting the protected subtree during the probe would fire
// authenticated SWR fetches like /api/v1/setting before auth is confirmed.
// Without this contract, a regression in protect.tsx silently re-introduces
// pre-auth traffic and a flash of protected UI before redirect.
let mockProfile: { id: number; role: number } | undefined
let mockLoading = false
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ profile: mockProfile, loading: mockLoading }),
}))
beforeEach(() => {
mockProfile = undefined
mockLoading = true
})
afterEach(() => {
document.body.innerHTML = ""
})
function renderAtPath(path: string, child: React.ReactNode) {
return import("@/routes/protect").then(({ default: ProtectedRoute }) => {
return act(async () => {
render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/dashboard/login"
element={<ProtectedRoute>{child}</ProtectedRoute>}
/>
<Route
path="/dashboard/*"
element={<ProtectedRoute>{child}</ProtectedRoute>}
/>
</Routes>
</MemoryRouter>,
)
})
})
}
test("ProtectedRoute does not mount children for protected paths while auth is loading", async () => {
await renderAtPath("/dashboard", <div data-testid="protected-child">protected</div>)
expect(document.querySelector("[data-testid='protected-child']")).toBeNull()
})
test("ProtectedRoute renders children on the login page even while auth is loading", async () => {
await renderAtPath("/dashboard/login", <div data-testid="login-child">login</div>)
expect(document.querySelector("[data-testid='login-child']")).not.toBeNull()
})
test("ProtectedRoute redirects unauthenticated users without mounting protected children", async () => {
mockLoading = false
mockProfile = undefined
const { default: ProtectedRoute } = await import("@/routes/protect")
await act(async () => {
render(
<MemoryRouter initialEntries={["/dashboard"]}>
<Routes>
<Route
path="/dashboard/login"
element={
<ProtectedRoute>
<div data-testid="login-child">login</div>
</ProtectedRoute>
}
/>
<Route
path="/dashboard/*"
element={
<ProtectedRoute>
<div data-testid="protected-child">protected</div>
</ProtectedRoute>
}
/>
</Routes>
</MemoryRouter>,
)
})
expect(document.querySelector("[data-testid='protected-child']")).toBeNull()
expect(document.querySelector("[data-testid='login-child']")).not.toBeNull()
})
test("ProtectedRoute renders children once an authenticated profile resolves", async () => {
mockLoading = false
mockProfile = { id: 1, role: 0 }
await renderAtPath("/dashboard", <div data-testid="protected-child">protected</div>)
expect(document.querySelector("[data-testid='protected-child']")).not.toBeNull()
})
+74
View File
@@ -0,0 +1,74 @@
import { act, render } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
vi.mock("sonner", () => ({ toast: () => {} }))
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: { language: "en", changeLanguage: () => {} },
}),
initReactI18next: { type: "3rdParty", init: () => undefined },
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
}))
let mockProfile: { id: number; role: number } | undefined
let mockLoading = false
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ profile: mockProfile, loading: mockLoading }),
}))
vi.mock("@/hooks/useSetting", () => ({
default: () => ({ data: undefined, mutate: () => {} }),
}))
vi.mock("@/hooks/useNotfication", () => ({
useNotification: () => ({ notifierGroup: [] }),
}))
vi.mock("@/api/settings", () => ({ updateSettings: vi.fn() }))
const navigateRenders: string[] = []
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<any>("react-router-dom")
return {
...actual,
Navigate: ({ to }: { to: string }) => {
navigateRenders.push(to)
return <div data-testid="nav-stub">redirect:{to}</div>
},
}
})
beforeEach(() => {
navigateRenders.length = 0
mockProfile = undefined
mockLoading = true
})
afterEach(() => {
document.body.innerHTML = ""
})
// SettingsPage 在 profile 还没 fetch 完成时不能就把用户当作非管理员重定向。
// useAuth.loading=true 表示请求未回来;此时必须按"加载中"渲染,不能 Navigate
// 到 /dashboard/settings/api-tokens,否则管理员每次直达 /dashboard/settings
// 都会先闪一下到 api-tokens 页。
test("SettingsPage waits for auth load before redirecting non-admin", async () => {
const { default: SettingsPage } = await import("@/routes/settings")
await act(async () => {
render(<SettingsPage />)
})
expect(navigateRenders).toEqual([])
})
// 一旦 loading=false 且确认 profile 不是 admin,才允许跳转。
test("SettingsPage redirects to api-tokens once auth resolves and user is not admin", async () => {
mockLoading = false
mockProfile = { id: 1, role: 1 }
const { default: SettingsPage } = await import("@/routes/settings")
await act(async () => {
render(<SettingsPage />)
})
expect(navigateRenders).toContain("/dashboard/settings/api-tokens")
})
+109
View File
@@ -0,0 +1,109 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const toastCalls: string[] = []
vi.mock("sonner", () => ({
toast: (msg: string) => {
toastCalls.push(msg)
},
}))
const changeLanguageCalls: string[] = []
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: {
language: "en",
changeLanguage: (lng: string) => {
changeLanguageCalls.push(lng)
},
},
}),
initReactI18next: { type: "3rdParty", init: () => undefined },
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
}))
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ profile: { id: 1, role: 0 }, loading: false }),
}))
const validConfig = {
config: {
site_name: "Nezha",
language: "zh-CN",
user_template: "user-dist",
cover: 1,
ip_change_notification_group_id: 0,
},
frontend_templates: [],
}
vi.mock("@/hooks/useSetting", () => ({
default: () => ({ data: validConfig, mutate: vi.fn() }),
}))
vi.mock("@/hooks/useNotfication", () => ({
useNotification: () => ({ notifierGroup: [] }),
}))
const updateSettings = vi.fn()
vi.mock("@/api/settings", () => ({
updateSettings: (...args: unknown[]) => updateSettings(...args),
}))
beforeEach(() => {
toastCalls.length = 0
changeLanguageCalls.length = 0
updateSettings.mockReset()
})
afterEach(() => {
document.body.innerHTML = ""
})
test("SettingsPage does not toast Success when updateSettings rejects", async () => {
updateSettings.mockRejectedValue(new Error("boom"))
const { default: SettingsPage } = await import("@/routes/settings")
await act(async () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>,
)
})
const submit = screen.getByRole("button", { name: /Confirm|Submit|Save/i })
await act(async () => {
fireEvent.click(submit)
})
await waitFor(() => expect(updateSettings).toHaveBeenCalled())
expect(toastCalls).toContain("Error")
expect(toastCalls).not.toContain("Success")
expect(changeLanguageCalls).toEqual([])
})
test("SettingsPage toasts Success when updateSettings resolves", async () => {
updateSettings.mockResolvedValue(undefined)
const { default: SettingsPage } = await import("@/routes/settings")
await act(async () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>,
)
})
const submit = screen.getByRole("button", { name: /Confirm|Submit|Save/i })
await act(async () => {
fireEvent.click(submit)
})
await waitFor(() => expect(updateSettings).toHaveBeenCalled())
expect(toastCalls).toContain("Success")
expect(toastCalls).not.toContain("Error")
})
+37
View File
@@ -0,0 +1,37 @@
class TestWorker implements Worker {
onmessage: ((this: Worker, ev: MessageEvent) => unknown) | null = null
onmessageerror: ((this: Worker, ev: MessageEvent) => unknown) | null = null
onerror: ((this: AbstractWorker, ev: ErrorEvent) => unknown) | null = null
addEventListener(): void {}
removeEventListener(): void {}
dispatchEvent(): boolean {
return true
}
postMessage(): void {}
terminate(): void {}
}
class TestResizeObserver implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
class TestIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null
readonly rootMargin = ""
readonly scrollMargin = ""
readonly thresholds: ReadonlyArray<number> = []
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
}
globalThis.Worker = TestWorker
globalThis.ResizeObserver = TestResizeObserver
globalThis.IntersectionObserver = TestIntersectionObserver
+135
View File
@@ -0,0 +1,135 @@
import type { ModelServerTransfer } from "@/types"
import { act, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const toastCalls: Array<{ title: string; description: string }> = []
vi.mock("sonner", () => ({
toast: (title: string, opts?: { description?: string }) => {
toastCalls.push({ title, description: opts?.description ?? "" })
},
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
initReactI18next: { type: "3rdParty", init: () => undefined },
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
}))
const cancelServerTransfer = vi.fn()
const retryServerTransfer = vi.fn()
vi.mock("@/api/transfer", () => ({
cancelServerTransfer: (...args: unknown[]) => cancelServerTransfer(...args),
retryServerTransfer: (...args: unknown[]) => retryServerTransfer(...args),
}))
const swrFetcher = vi.fn()
vi.mock("@/api/api", () => ({
swrFetcher: (...args: unknown[]) => swrFetcher(...args),
}))
vi.mock("swr", () => ({
default: (_key: string, _fetcher: unknown) => ({
data: mockedRows,
mutate: vi.fn(),
error: undefined,
}),
}))
let mockProfile: { id: number; role: number } | undefined
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ profile: mockProfile }),
}))
vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector?: (s: { profile?: { id: number; role: number } }) => unknown) => {
const store = { profile: mockProfile }
return selector ? selector(store) : store
},
}))
let mockedRows: ModelServerTransfer[] = []
beforeEach(() => {
toastCalls.length = 0
cancelServerTransfer.mockReset()
retryServerTransfer.mockReset()
mockedRows = []
mockProfile = undefined
})
afterEach(() => {
document.body.innerHTML = ""
})
function makeRow(overrides: Partial<ModelServerTransfer>): ModelServerTransfer {
return {
id: 1,
server_id: 10,
from_user_id: 100,
to_user_id: 200,
initiator_id: 100,
status: 0,
last_error: "",
acked_at: "",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
...overrides,
} as ModelServerTransfer
}
async function renderPage() {
const { default: TransferPage } = await import("@/routes/transfer")
await act(async () => {
render(<TransferPage />)
})
await waitFor(() => {
expect(screen.getByText("Transfer.Title")).toBeTruthy()
})
}
test("non-admin member who is the FromUserID sees Cancel for pending rows but NOT Retry for terminal rows", async () => {
mockProfile = { id: 100, role: 1 }
mockedRows = [
makeRow({ id: 1, status: 0, from_user_id: 100 }),
makeRow({ id: 2, status: 2, from_user_id: 100 }),
makeRow({ id: 3, status: 4, from_user_id: 100 }),
]
await renderPage()
expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBeGreaterThan(0)
expect(
screen.queryAllByRole("button", { name: "Transfer.Retry" }),
"Retry is admin-only on the backend; rendering it for members produces guaranteed permission_denied on click",
).toHaveLength(0)
})
test("non-admin member who is only the ToUserID or InitiatorID sees neither Cancel nor Retry", async () => {
mockProfile = { id: 200, role: 1 }
mockedRows = [
makeRow({ id: 1, status: 0, from_user_id: 100, to_user_id: 200 }),
makeRow({ id: 2, status: 3, from_user_id: 100, to_user_id: 200 }),
]
await renderPage()
expect(
screen.queryAllByRole("button", { name: "Cancel" }),
"backend cancelServerTransfer rejects non-admins that are not the FromUserID; UI must not pretend it works",
).toHaveLength(0)
expect(
screen.queryAllByRole("button", { name: "Transfer.Retry" }),
"backend retryServerTransfer is admin-only; non-admin must not see the button",
).toHaveLength(0)
})
test("admin sees both Cancel for pending and Retry for terminal", async () => {
mockProfile = { id: 1, role: 0 }
mockedRows = [makeRow({ id: 1, status: 0 }), makeRow({ id: 2, status: 2 })]
await renderPage()
expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBe(1)
expect(screen.queryAllByRole("button", { name: "Transfer.Retry" }).length).toBe(1)
})
+368 -349
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@ import { ModelProfile } from "@/types"
export interface AuthContextProps {
profile: ModelProfile | undefined
loading: boolean
login: (username: string, password: string) => void
loginOauth2: () => void
logout: () => void

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