mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 09:40:13 +00:00
chore: merge upstream/main into admin-frontend-domain preserving custom domain and branding features
This commit is contained in:
@@ -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:
|
||||||
|
- "*"
|
||||||
@@ -10,14 +10,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.head_ref }}
|
ref: ${{ github.head_ref }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
- name: Set up Bun
|
- name: Set up Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: "latest"
|
bun-version: "latest"
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Commit and push changes
|
- name: Commit and push changes
|
||||||
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
|
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:
|
with:
|
||||||
commit_message: "chore: auto-fix linting and formatting issues"
|
commit_message: "chore: auto-fix linting and formatting issues"
|
||||||
commit_options: "--no-verify"
|
commit_options: "--no-verify"
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add PR comment
|
- name: Add PR comment
|
||||||
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
|
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -12,12 +12,12 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Bun
|
- name: Set up Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: "latest"
|
bun-version: "latest"
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
run: zip -r dist.zip dist
|
run: zip -r dist.zip dist
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: dist.zip
|
files: dist.zip
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|||||||
@@ -24,3 +24,9 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
bun.lock
|
bun.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
/blob-report
|
||||||
|
/playwright/.cache
|
||||||
|
|||||||
@@ -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 +1,2 @@
|
|||||||
src/main.tsx
|
src/main.tsx
|
||||||
|
.github/workflows/*
|
||||||
|
|||||||
@@ -5,3 +5,61 @@
|
|||||||
```bash
|
```bash
|
||||||
npx swagger-typescript-api -p http://localhost:8008/swagger/doc.json -o ./src/types -n api.ts --no-client --union-enums
|
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.
|
||||||
|
|||||||
@@ -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],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Generated
+2977
-4382
File diff suppressed because it is too large
Load Diff
+62
-59
@@ -7,78 +7,81 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build-ignore-error": "vite build",
|
"build-ignore-error": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "oxlint .",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "oxlint --fix .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"preview": "vite preview"
|
"test": "vitest run",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"e2e:install": "playwright install --with-deps chromium"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.9.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.22",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^9.1.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/luxon": "^3.7.1",
|
"@types/luxon": "^3.7.5",
|
||||||
"@xterm/addon-attach": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.4.0",
|
||||||
"framer-motion": "^12.23.22",
|
"framer-motion": "^13.1.1",
|
||||||
"i18next": "^25.5.3",
|
"i18next": "^26.4.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jotai-zustand": "^0.6.0",
|
"jotai-zustand": "^0.6.0",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^1.34.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.8",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.86.0",
|
||||||
"react-i18next": "^16.0.0",
|
"react-i18next": "^17.0.12",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.18.2",
|
||||||
"react-virtuoso": "^4.14.1",
|
"react-virtuoso": "^4.18.12",
|
||||||
"sonner": "^2.0.7",
|
"simple-icons": "^16.28.0",
|
||||||
"swr": "^2.3.6",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.3.1",
|
"swr": "^2.5.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.37.0",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/node": "^24.7.0",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@types/react": "^19.2.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/node": "^26.3.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@types/react": "^19.2.18",
|
||||||
"autoprefixer": "^10.4.21",
|
"@types/react-dom": "^19.2.5",
|
||||||
"eslint": "^9.37.0",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.0",
|
"autoprefixer": "^10.5.4",
|
||||||
"eslint-plugin-react-refresh": "^0.4.23",
|
"jsdom": "^30.0.1",
|
||||||
"globals": "^16.4.0",
|
"oxlint": "1.80.0",
|
||||||
"postcss": "8.4.24",
|
"postcss": "8.5.26",
|
||||||
"swagger-typescript-api": "^13.2.15",
|
"swagger-typescript-api": "^13.12.6",
|
||||||
"tailwindcss": "3.4.19",
|
"tailwindcss": "4.3.3",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~7.0.2",
|
||||||
"typescript-eslint": "^8.46.0",
|
"vite": "^8.2.2",
|
||||||
"vite": "^7.1.9"
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
+69
-8
@@ -23,17 +23,56 @@ export enum FetcherMethod {
|
|||||||
|
|
||||||
let lastestRefreshTokenAt = 0
|
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> {
|
export async function fetcher<T>(method: FetcherMethod, path: string, data?: any): Promise<T> {
|
||||||
let response
|
let response
|
||||||
if (method === FetcherMethod.GET || method === FetcherMethod.DELETE) {
|
if (method === FetcherMethod.GET || method === FetcherMethod.DELETE) {
|
||||||
response = await fetch(buildUrl(path, data), {
|
response = await fetch(buildUrl(path, data), {
|
||||||
method: method,
|
method: method,
|
||||||
|
headers: csrfHeaders(method, path),
|
||||||
})
|
})
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
response = await fetch(path, {
|
response = await fetch(path, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...csrfHeaders(method, path),
|
||||||
},
|
},
|
||||||
body: data ? JSON.stringify(data) : null,
|
body: data ? JSON.stringify(data) : null,
|
||||||
})
|
})
|
||||||
@@ -41,23 +80,45 @@ export async function fetcher<T>(method: FetcherMethod, path: string, data?: any
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(response.statusText)
|
throw new Error(response.statusText)
|
||||||
}
|
}
|
||||||
const responseData: CommonResponse<T> = await response.json()
|
const text = await response.text()
|
||||||
if (!responseData.success) {
|
if (text !== "") {
|
||||||
throw new Error(responseData.error)
|
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 (
|
if (
|
||||||
document.cookie &&
|
document.cookie &&
|
||||||
(!lastestRefreshTokenAt || Date.now() - lastestRefreshTokenAt > 1000 * 60 * 60)
|
(!lastestRefreshTokenAt || Date.now() - lastestRefreshTokenAt > 1000 * 60 * 60)
|
||||||
) {
|
) {
|
||||||
lastestRefreshTokenAt = Date.now()
|
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) {
|
export async function swrFetcher<T>(input: string | URL | globalThis.Request, init?: RequestInit) {
|
||||||
return fetcher<T>(init?.method as FetcherMethod, input.toString(), init?.body)
|
// SWR 默认不带 init,method 为 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
@@ -15,5 +15,5 @@ export const deleteCron = async (id: number[]): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const runCron = 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
@@ -3,5 +3,5 @@ import { ModelCreateFMResponse } from "@/types"
|
|||||||
import { FetcherMethod, fetcher } from "./api"
|
import { FetcherMethod, fetcher } from "./api"
|
||||||
|
|
||||||
export const createFM = async (id: string): Promise<ModelCreateFMResponse> => {
|
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
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ModelBatchMoveServerForm,
|
ModelBatchMoveServerForm,
|
||||||
|
ModelBatchMoveServerResult,
|
||||||
ModelServer,
|
ModelServer,
|
||||||
ModelServerConfigForm,
|
ModelServerConfigForm,
|
||||||
ModelServerForm,
|
ModelServerForm,
|
||||||
@@ -16,8 +17,20 @@ export const deleteServer = async (id: number[]): Promise<void> => {
|
|||||||
return fetcher<void>(FetcherMethod.POST, "/api/v1/batch-delete/server", id)
|
return fetcher<void>(FetcherMethod.POST, "/api/v1/batch-delete/server", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const batchMoveServer = async (data: ModelBatchMoveServerForm): Promise<void> => {
|
// batchMoveServer kicks off one ServerTransfer per id and returns a per-id
|
||||||
return fetcher<void>(FetcherMethod.POST, "/api/v1/batch-move/server", data)
|
// 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> => {
|
export const forceUpdateServer = async (id: number[]): Promise<ModelServerTaskResponse> => {
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ import { ModelAlertRule } from "@/types"
|
|||||||
import { triggerModes } from "@/types"
|
import { triggerModes } from "@/types"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm, useWatch } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { KeyedMutator } from "swr"
|
import { KeyedMutator } from "swr"
|
||||||
@@ -50,13 +50,15 @@ interface AlertRuleCardProps {
|
|||||||
mutate: KeyedMutator<ModelAlertRule[]>
|
mutate: KeyedMutator<ModelAlertRule[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cycleUnitSchema = z.enum(["hour", "day", "week", "month", "year"])
|
||||||
|
|
||||||
const ruleSchema = z.object({
|
const ruleSchema = z.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
min: z.number().optional(),
|
min: z.number().optional(),
|
||||||
max: z.number().optional(),
|
max: z.number().optional(),
|
||||||
cycle_start: z.string().optional(),
|
cycle_start: z.string().optional(),
|
||||||
cycle_interval: z.number().optional(),
|
cycle_interval: z.number().optional(),
|
||||||
cycle_unit: z.enum(["hour", "day", "week", "month", "year"]).optional(),
|
cycle_unit: cycleUnitSchema.optional(),
|
||||||
duration: z.number().optional(),
|
duration: z.number().optional(),
|
||||||
cover: z.number().int().min(0),
|
cover: z.number().int().min(0),
|
||||||
ignore: z.record(z.string(), z.boolean()).optional(),
|
ignore: z.record(z.string(), z.boolean()).optional(),
|
||||||
@@ -71,7 +73,7 @@ const alertRuleFormSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,10 +94,12 @@ const alertRuleFormSchema = z.object({
|
|||||||
export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) => {
|
export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) => {
|
||||||
const { t } = useTranslation()
|
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({
|
const form = useForm<AlertRuleFormInput, unknown, AlertRuleFormData>({
|
||||||
resolver: zodResolver(alertRuleFormSchema) as any,
|
resolver: zodResolver(alertRuleFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
...data,
|
...data,
|
||||||
@@ -124,14 +128,16 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
// 结构化规则编辑状态:从已有数据或 rules_raw 初始化
|
// 结构化规则编辑状态:从已有数据或 rules_raw 初始化
|
||||||
const initialRules = (() => {
|
const initialRules = (() => {
|
||||||
try {
|
try {
|
||||||
if (data?.rules) return data.rules as any[]
|
if (data?.rules) return data.rules
|
||||||
const raw = form.getValues("rules_raw")
|
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 {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
const [rulesUI, setRulesUI] = useState<any[]>(initialRules)
|
const [rulesUI, setRulesUI] = useState<AlertRuleEntry[]>(initialRules)
|
||||||
|
|
||||||
// 同步到 rules_raw(提交仍走 JSON 字符串)
|
// 同步到 rules_raw(提交仍走 JSON 字符串)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -140,17 +146,22 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [rulesUI])
|
}, [form, rulesUI])
|
||||||
|
|
||||||
|
const rulesRaw = useWatch({ control: form.control, name: "rules_raw" })
|
||||||
|
|
||||||
const onSubmit = async (values: AlertRuleFormData) => {
|
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.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)
|
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 {
|
try {
|
||||||
data?.id
|
if (data?.id) {
|
||||||
? await updateAlertRule(data.id, requiredFields)
|
await updateAlertRule(data.id, requiredFields)
|
||||||
: await createAlertRule(requiredFields)
|
} else {
|
||||||
|
await createAlertRule(requiredFields)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -184,10 +195,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
<DialogDescription />
|
<DialogDescription />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
|
||||||
onSubmit={form.handleSubmit(onSubmit as any)}
|
|
||||||
className="space-y-2 my-2"
|
|
||||||
>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -531,7 +539,10 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
const next = [...rulesUI]
|
const next = [...rulesUI]
|
||||||
next[idx] = {
|
next[idx] = {
|
||||||
...next[idx],
|
...next[idx],
|
||||||
cycle_unit: val,
|
cycle_unit:
|
||||||
|
cycleUnitSchema.parse(
|
||||||
|
val,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
setRulesUI(next)
|
setRulesUI(next)
|
||||||
}}
|
}}
|
||||||
@@ -603,7 +614,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="resize-y"
|
className="resize-y"
|
||||||
value={form.watch("rules_raw")}
|
value={rulesRaw}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// 同步到结构化编辑器
|
// 同步到结构化编辑器
|
||||||
form.setValue("rules_raw", e.target.value, {
|
form.setValue("rules_raw", e.target.value, {
|
||||||
@@ -630,7 +641,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
placeholder={t("Search")}
|
placeholder={t("Search")}
|
||||||
options={ngroupList}
|
options={ngroupList}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value.toString()}
|
defaultValue={String(field.value ?? "")}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ export const BatchMoveServerIcon: React.FC<BatchMoveServerIconProps> = ({
|
|||||||
const [toUserId, setToUserId] = useState<number | undefined>(undefined)
|
const [toUserId, setToUserId] = useState<number | undefined>(undefined)
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
|
let results
|
||||||
try {
|
try {
|
||||||
await batchMoveServer({
|
results = await batchMoveServer({
|
||||||
ids: serverIds,
|
ids: serverIds,
|
||||||
to_user: toUserId!,
|
to_user: toUserId!,
|
||||||
})
|
})
|
||||||
@@ -45,7 +46,30 @@ export const BatchMoveServerIcon: React.FC<BatchMoveServerIconProps> = ({
|
|||||||
})
|
})
|
||||||
return
|
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)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -63,21 +63,22 @@ const cronFormSchema = z.object({
|
|||||||
notification_group_id: z.coerce.number().int(),
|
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 }) => {
|
export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const form = useForm<CronFormData>({
|
const form = useForm<CronFormInput, unknown, CronFormData>({
|
||||||
resolver: zodResolver(cronFormSchema as any),
|
resolver: zodResolver(cronFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
task_type: data.task_type ?? 0,
|
task_type: data.task_type ?? 0,
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
scheduler: data.scheduler ?? "",
|
scheduler: data.scheduler ?? "",
|
||||||
command: (data as any).command ?? "",
|
command: data.command ?? "",
|
||||||
servers: data.servers ?? [],
|
servers: data.servers ?? [],
|
||||||
cover: data.cover ?? 0,
|
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,
|
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) => {
|
const onSubmit = async (values: CronFormData) => {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -267,7 +272,7 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
|
|||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
options={ngroupList}
|
options={ngroupList}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value.toString()}
|
defaultValue={String(field.value ?? "")}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -282,7 +287,7 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
|
|||||||
+37
-24
@@ -67,28 +67,29 @@ const ddnsFormSchema = z.object({
|
|||||||
webhook_headers: asOptionalField(z.string()),
|
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 }) => {
|
export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const form = useForm<DDNSFormData>({
|
const form = useForm<DDNSFormInput, unknown, DDNSFormData>({
|
||||||
resolver: zodResolver(ddnsFormSchema as any),
|
resolver: zodResolver(ddnsFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
max_retries: data.max_retries ?? 3,
|
max_retries: data.max_retries ?? 3,
|
||||||
enable_ipv4: (data as any).enable_ipv4 ?? false,
|
enable_ipv4: data.enable_ipv4 ?? false,
|
||||||
enable_ipv6: (data as any).enable_ipv6 ?? false,
|
enable_ipv6: data.enable_ipv6 ?? false,
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
provider: data.provider ?? "dummy",
|
provider: data.provider ?? "dummy",
|
||||||
domains: data.domains ?? [],
|
domains: data.domains ?? [],
|
||||||
domains_raw: conv.arrToStr(data.domains ?? []),
|
domains_raw: conv.arrToStr(data.domains ?? []),
|
||||||
access_id: (data as any).access_id ?? "",
|
access_id: data.access_id ?? "",
|
||||||
access_secret: (data as any).access_secret ?? "",
|
access_secret: data.access_secret ?? "",
|
||||||
webhook_url: (data as any).webhook_url ?? "",
|
webhook_url: data.webhook_url ?? "",
|
||||||
webhook_method: (data as any).webhook_method,
|
webhook_method: data.webhook_method,
|
||||||
webhook_request_type: (data as any).webhook_request_type,
|
webhook_request_type: data.webhook_request_type,
|
||||||
webhook_request_body: (data as any).webhook_request_body ?? "",
|
webhook_request_body: data.webhook_request_body ?? "",
|
||||||
webhook_headers: (data as any).webhook_headers ?? "",
|
webhook_headers: data.webhook_headers ?? "",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
@@ -116,7 +117,11 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
|
|||||||
const onSubmit = async (values: DDNSFormData) => {
|
const onSubmit = async (values: DDNSFormData) => {
|
||||||
try {
|
try {
|
||||||
values.domains = conv.strToArr(values.domains_raw)
|
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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -227,15 +232,23 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="max_retries"
|
name="max_retries"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const { value, ...fieldProps } = field
|
||||||
<FormLabel>{t("MaximumRetryAttempts")}</FormLabel>
|
return (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input type="number" placeholder="3" {...field} />
|
<FormLabel>{t("MaximumRetryAttempts")}</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input
|
||||||
</FormItem>
|
type="number"
|
||||||
)}
|
placeholder="3"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
{...fieldProps}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -351,7 +364,7 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
@@ -371,7 +384,7 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
|
|||||||
+107
-89
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { useMediaQuery } from "@/hooks/useMediaQuery"
|
import { useMediaQuery } from "@/hooks/useMediaQuery"
|
||||||
|
import { virtualizedTableFeatures } from "@/lib/table"
|
||||||
import { copyToClipboard, fm, formatPath, fmWorker as worker } from "@/lib/utils"
|
import { copyToClipboard, fm, formatPath, fmWorker as worker } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
FMEntry,
|
FMEntry,
|
||||||
@@ -37,7 +38,7 @@ import {
|
|||||||
import { ColumnDef } from "@tanstack/react-table"
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { Row, flexRender } from "@tanstack/react-table"
|
import { Row, flexRender } from "@tanstack/react-table"
|
||||||
import { File, Folder } from "lucide-react"
|
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 { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -59,6 +60,10 @@ interface FMProps {
|
|||||||
wsUrl: string
|
wsUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VirtualizedTableRowProps = HTMLAttributes<HTMLTableRowElement> & {
|
||||||
|
"data-index"?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
|
const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false
|
||||||
for (let i = 0; i < a.length; i++) {
|
for (let i = 0; i < a.length; i++) {
|
||||||
@@ -67,21 +72,20 @@ const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
|
|||||||
return true
|
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 { t } = useTranslation()
|
||||||
const fmRef = useRef<HTMLDivElement>(null)
|
const fmRef = useRef<HTMLDivElement>(null)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const tRef = useRef(t)
|
||||||
useEffect(() => {
|
tRef.current = t
|
||||||
return () => {
|
|
||||||
wsRef.current?.close()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const [dOpen, setdOpen] = useState(false)
|
const [dOpen, setdOpen] = useState(false)
|
||||||
const [uOpen, setuOpen] = useState(false)
|
const [uOpen, setuOpen] = useState(false)
|
||||||
|
|
||||||
const columns: ColumnDef<FMEntry>[] = [
|
const columns: ColumnDef<typeof virtualizedTableFeatures, FMEntry>[] = [
|
||||||
{
|
{
|
||||||
id: "type",
|
id: "type",
|
||||||
header: () => <span>{t("Type")}</span>,
|
header: () => <span>{t("Type")}</span>,
|
||||||
@@ -119,10 +123,9 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const tableRowComponent = (rows: Row<FMEntry>[]) =>
|
const tableRowComponent = (rows: Row<typeof virtualizedTableFeatures, FMEntry>[]) =>
|
||||||
function getTableRow(props: HTMLAttributes<HTMLTableRowElement>) {
|
function getTableRow(props: VirtualizedTableRowProps) {
|
||||||
// @ts-expect-error data-index is a valid attribute
|
const index = Number(props["data-index"])
|
||||||
const index = props["data-index"]
|
|
||||||
const row = rows[index]
|
const row = rows[index]
|
||||||
|
|
||||||
if (!row) return null
|
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(() => {
|
useEffect(() => {
|
||||||
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
|
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
|
||||||
switch (event.data.type) {
|
switch (event.data.type) {
|
||||||
@@ -265,25 +203,101 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||||
}
|
}
|
||||||
}, [worker, dOpen])
|
}, [dOpen])
|
||||||
|
|
||||||
const [currentPath, setPath] = useState("")
|
const [currentPath, setPath] = useState("")
|
||||||
useEffect(() => {
|
const currentPathRef = useRef(currentPath)
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
currentPathRef.current = currentPath
|
||||||
listFile()
|
|
||||||
}
|
|
||||||
}, [wsRef.current, currentPath])
|
|
||||||
|
|
||||||
const listFile = () => {
|
const listFile = useCallback(() => {
|
||||||
const prefix = new Int8Array([FMOpcode.List])
|
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)
|
const msg = new Int8Array(prefix.length + pathMsg.length)
|
||||||
msg.set(prefix)
|
msg.set(prefix)
|
||||||
msg.set(pathMsg, prefix.length)
|
msg.set(pathMsg, prefix.length)
|
||||||
|
|
||||||
wsRef.current?.send(msg)
|
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) => {
|
const downloadFile = (basename: string) => {
|
||||||
currentBasename.current = basename
|
currentBasename.current = basename
|
||||||
@@ -331,9 +345,13 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(formatPath(currentPath))
|
await copyToClipboard(formatPath(currentPath))
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const description =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t("Results.UnExpectedError")
|
||||||
toast("FM" + " " + t("Error"), {
|
toast("FM" + " " + t("Error"), {
|
||||||
description: error.message,
|
description,
|
||||||
})
|
})
|
||||||
console.error("copy error: ", error)
|
console.error("copy error: ", error)
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-26
@@ -51,6 +51,7 @@ const pages = [
|
|||||||
{ href: "/dashboard/nat", label: i18next.t("NATT") },
|
{ href: "/dashboard/nat", label: i18next.t("NATT") },
|
||||||
{ href: "/dashboard/domain", label: i18next.t("Domain") }, // <-- 新增的域名监控链接
|
{ href: "/dashboard/domain", label: i18next.t("Domain") }, // <-- 新增的域名监控链接
|
||||||
{ href: "/dashboard/server-group", label: i18next.t("Group") },
|
{ href: "/dashboard/server-group", label: i18next.t("Group") },
|
||||||
|
{ href: "/dashboard/transfer", label: i18next.t("Transfer.Title") },
|
||||||
]
|
]
|
||||||
// ^^^^^^^^^^^ 1. 在这里为移动端菜单添加新页面 ^^^^^^^^^^^
|
// ^^^^^^^^^^^ 1. 在这里为移动端菜单添加新页面 ^^^^^^^^^^^
|
||||||
// =======================================================
|
// =======================================================
|
||||||
@@ -59,6 +60,7 @@ export default function Header() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const profile = useMainStore((store) => store.profile)
|
const profile = useMainStore((store) => store.profile)
|
||||||
|
const isAdmin = profile?.role === 0
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isDesktop = useMediaQuery("(min-width: 890px)")
|
const isDesktop = useMediaQuery("(min-width: 890px)")
|
||||||
@@ -146,18 +148,20 @@ export default function Header() {
|
|||||||
{t("Profile")}
|
{t("Profile")}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
{isAdmin && (
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setDropdownOpen(false)
|
onClick={() => {
|
||||||
navigate("/dashboard/settings")
|
setDropdownOpen(false)
|
||||||
}}
|
navigate("/dashboard/settings")
|
||||||
className="cursor-pointer"
|
}}
|
||||||
>
|
className="cursor-pointer"
|
||||||
<div className="flex items-center gap-2 w-full">
|
>
|
||||||
<Settings />
|
<div className="flex items-center gap-2 w-full">
|
||||||
{t("Settings")}
|
<Settings />
|
||||||
</div>
|
{t("Settings")}
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -264,6 +268,15 @@ export default function Header() {
|
|||||||
<Link to="/dashboard/server-group">{t("Group")}</Link>
|
<Link to="/dashboard/server-group">{t("Group")}</Link>
|
||||||
</NzNavigationMenuLink>
|
</NzNavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NzNavigationMenuLink
|
||||||
|
asChild
|
||||||
|
active={location.pathname === "/dashboard/transfer"}
|
||||||
|
className={navigationMenuTriggerStyle()}
|
||||||
|
>
|
||||||
|
<Link to="/dashboard/transfer">{t("Transfer.Title")}</Link>
|
||||||
|
</NzNavigationMenuLink>
|
||||||
|
</NavigationMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -353,18 +366,20 @@ export default function Header() {
|
|||||||
{t("Profile")}
|
{t("Profile")}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
{isAdmin && (
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setDropdownOpen(false)
|
onClick={() => {
|
||||||
navigate("/dashboard/settings")
|
setDropdownOpen(false)
|
||||||
}}
|
navigate("/dashboard/settings")
|
||||||
className="cursor-pointer"
|
}}
|
||||||
>
|
className="cursor-pointer"
|
||||||
<div className="flex items-center gap-2 w-full">
|
>
|
||||||
<Settings />
|
<div className="flex items-center gap-2 w-full">
|
||||||
{t("Settings")}
|
<Settings />
|
||||||
</div>
|
{t("Settings")}
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={logout} className="cursor-pointer">
|
<DropdownMenuItem onClick={logout} className="cursor-pointer">
|
||||||
@@ -399,8 +414,7 @@ const useInterval = (callback: () => void, delay?: number | null) => {
|
|||||||
function Overview() {
|
function Overview() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const profile = useMainStore((store) => store.profile)
|
const profile = useMainStore((store) => store.profile)
|
||||||
const timeOption = DateTime.TIME_SIMPLE
|
const timeOption = { ...DateTime.TIME_SIMPLE, hour12: true }
|
||||||
timeOption.hour12 = true
|
|
||||||
const [timeString, setTimeString] = useState(
|
const [timeString, setTimeString] = useState(
|
||||||
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
|
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,11 +147,11 @@ const generateCommand = (
|
|||||||
const env = envParts.join(" ")
|
const env = envParts.join(" ")
|
||||||
|
|
||||||
const envWinParts = [
|
const envWinParts = [
|
||||||
`$env:NZ_SERVER=\"${install_host}\";`,
|
`$env:NZ_SERVER="${install_host}";`,
|
||||||
`$env:NZ_TLS=\"${tls || false}\";`,
|
`$env:NZ_TLS="${tls || false}";`,
|
||||||
`$env:NZ_CLIENT_SECRET=\"${agent_secret}\";`,
|
`$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("")
|
const env_win = envWinParts.join("")
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -161,6 +161,7 @@ const generateCommand = (
|
|||||||
}
|
}
|
||||||
case OSTypes.Windows: {
|
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`
|
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: {
|
default: {
|
||||||
throw new Error(`Unknown OS: ${type}`)
|
throw new Error(`Unknown OS: ${type}`)
|
||||||
|
|||||||
+31
-17
@@ -46,16 +46,17 @@ const natFormSchema = z.object({
|
|||||||
domain: z.string(),
|
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 }) => {
|
export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const form = useForm<NatFormData>({
|
const form = useForm<NatFormInput, unknown, NatFormData>({
|
||||||
resolver: zodResolver(natFormSchema as any),
|
resolver: zodResolver(natFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
enabled: (data as any).enabled ?? false,
|
enabled: data.enabled ?? false,
|
||||||
server_id: data.server_id ?? 0,
|
server_id: data.server_id ?? 0,
|
||||||
host: data.host ?? "",
|
host: data.host ?? "",
|
||||||
domain: data.domain ?? "",
|
domain: data.domain ?? "",
|
||||||
@@ -76,11 +77,16 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
|||||||
|
|
||||||
const onSubmit = async (values: NatFormData) => {
|
const onSubmit = async (values: NatFormData) => {
|
||||||
try {
|
try {
|
||||||
data?.id ? await updateNAT(data.id, values) : await createNAT(values)
|
if (data?.id) {
|
||||||
} catch (e) {
|
await updateNAT(data.id, values)
|
||||||
|
} else {
|
||||||
|
await createNAT(values)
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
description: t("Results.UnExpectedError"),
|
description:
|
||||||
|
e instanceof Error && e.message ? e.message : t("Results.UnExpectedError"),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -119,15 +125,23 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="server_id"
|
name="server_id"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const { value, ...fieldProps } = field
|
||||||
<FormLabel>{t("Server")} ID</FormLabel>
|
return (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input type="number" placeholder="1" {...field} />
|
<FormLabel>{t("Server")} ID</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input
|
||||||
</FormItem>
|
type="number"
|
||||||
)}
|
placeholder="1"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
{...fieldProps}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -169,7 +183,7 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">{t("Enable")}</Label>
|
<Label className="text-sm">{t("Enable")}</Label>
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ export const NotificationGroupCard: React.FC<NotificationGroupCardProps> = ({ da
|
|||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof notificationGroupFormSchema>) => {
|
const onSubmit = async (values: z.infer<typeof notificationGroupFormSchema>) => {
|
||||||
try {
|
try {
|
||||||
data?.group.id
|
if (data?.group.id) {
|
||||||
? await updateNotificationGroup(data.group.id, values)
|
await updateNotificationGroup(data.group.id, values)
|
||||||
: await createNotificationGroup(values)
|
} else {
|
||||||
|
await createNotificationGroup(values)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
|
|||||||
+19
-11
@@ -62,10 +62,14 @@ const notificationFormSchema = z.object({
|
|||||||
|
|
||||||
export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
||||||
const { t } = useTranslation()
|
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({
|
const form = useForm<NotificationFormInput, unknown, NotificationFormData>({
|
||||||
resolver: zodResolver(notificationFormSchema) as any,
|
resolver: zodResolver(notificationFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
@@ -75,10 +79,12 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
request_header: data.request_header ?? "",
|
request_header: data.request_header ?? "",
|
||||||
request_body: data.request_body ?? "",
|
request_body: data.request_body ?? "",
|
||||||
verify_tls: data.verify_tls ?? false,
|
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,
|
format_metric_units: data.format_metric_units ?? false,
|
||||||
type: data.type ?? 1,
|
type: (data as any)?.type ?? 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
: {
|
: {
|
||||||
name: "",
|
name: "",
|
||||||
url: "",
|
url: "",
|
||||||
@@ -98,9 +104,13 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const onSubmit = async (values: notificationFormData) => {
|
const onSubmit = async (values: NotificationFormData) => {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -128,10 +138,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
<DialogDescription />
|
<DialogDescription />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
|
||||||
onSubmit={form.handleSubmit(onSubmit as any)}
|
|
||||||
className="space-y-2 my-2"
|
|
||||||
>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -343,6 +350,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="justify-end">
|
<DialogFooter className="justify-end">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" className="my-2" variant="secondary">
|
<Button type="button" className="my-2" variant="secondary">
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const agentConfigSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,7 @@ const agentConfigSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -129,7 +129,7 @@ export const ServerConfigCard = ({ sid, menuItem = false, ...props }: ServerConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (open) fetchData()
|
if (open) fetchData()
|
||||||
}, [open])
|
}, [open, sid, t])
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(agentConfigSchema) as any,
|
resolver: zodResolver(agentConfigSchema) as any,
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ export const ServerGroupCard: React.FC<ServerGroupCardProps> = ({ data, mutate }
|
|||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof serverGroupFormSchema>) => {
|
const onSubmit = async (values: z.infer<typeof serverGroupFormSchema>) => {
|
||||||
try {
|
try {
|
||||||
data?.group.id
|
if (data?.group.id) {
|
||||||
? await updateServerGroup(data.group.id, values)
|
await updateServerGroup(data.group.id, values)
|
||||||
: await createServerGroup(values)
|
} else {
|
||||||
|
await createServerGroup(values)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import { asOptionalField } from "@/lib/utils"
|
|||||||
import { ModelServer } from "@/types"
|
import { ModelServer } from "@/types"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm, useWatch } from "react-hook-form"
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { KeyedMutator } from "swr"
|
import { KeyedMutator } from "swr"
|
||||||
@@ -55,7 +56,7 @@ const serverFormSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -88,6 +89,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const enableDDNS = useWatch({ control: form.control, name: "enable_ddns" })
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -189,7 +191,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{form.watch("enable_ddns") ? (
|
{enableDDNS ? (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control as any}
|
control={form.control as any}
|
||||||
@@ -331,6 +333,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter className="justify-end">
|
<DialogFooter className="justify-end">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" className="my-2" variant="secondary">
|
<Button type="button" className="my-2" variant="secondary">
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ const serviceFormSchema = z.object({
|
|||||||
cover: z.coerce.number().int().min(0),
|
cover: z.coerce.number().int().min(0),
|
||||||
display_index: z.coerce.number().int(),
|
display_index: z.coerce.number().int(),
|
||||||
duration: z.coerce.number().int().min(30),
|
duration: z.coerce.number().int().min(30),
|
||||||
enable_show_in_service: asOptionalField(z.boolean()),
|
|
||||||
enable_trigger_task: 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: z.array(z.number()),
|
||||||
fail_trigger_tasks_raw: z.string(),
|
fail_trigger_tasks_raw: z.string(),
|
||||||
latency_notify: asOptionalField(z.boolean()),
|
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.skip_servers = conv.arrToRecord(values.skip_servers_raw)
|
||||||
values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number)
|
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)
|
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 {
|
try {
|
||||||
data?.id
|
if (data?.id) {
|
||||||
? await updateService(data.id, requiredFields)
|
await updateService(data.id, requiredFields)
|
||||||
: await createService(requiredFields)
|
} else {
|
||||||
|
await createService(requiredFields)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -232,7 +235,7 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="enable_show_in_service"
|
name="hide_for_guest"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center space-x-2">
|
<FormItem className="flex items-center space-x-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -242,7 +245,7 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
|
|||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
{t("ShowInService")}
|
{t("HideForGuest")}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ export const SettingsTab = ({ className }: { className?: string }) => {
|
|||||||
const { profile } = useAuth()
|
const { profile } = useAuth()
|
||||||
|
|
||||||
const isAdmin = profile?.role === 0
|
const isAdmin = profile?.role === 0
|
||||||
|
const colsClass = isAdmin ? "grid-cols-5" : "grid-cols-1"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue={window.location.pathname} className={className}>
|
<Tabs defaultValue={window.location.pathname} className={className}>
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className={`grid w-full ${colsClass}`}>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<TabsTrigger value="/dashboard/settings" asChild>
|
<TabsTrigger value="/dashboard/settings" asChild>
|
||||||
@@ -20,13 +21,16 @@ export const SettingsTab = ({ className }: { className?: string }) => {
|
|||||||
<TabsTrigger value="/dashboard/settings/user" asChild>
|
<TabsTrigger value="/dashboard/settings/user" asChild>
|
||||||
<Link to="/dashboard/settings/user">{t("User")}</Link>
|
<Link to="/dashboard/settings/user">{t("User")}</Link>
|
||||||
</TabsTrigger>
|
</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/online-user" asChild>
|
<TabsTrigger value="/dashboard/settings/api-tokens" asChild>
|
||||||
<Link to="/dashboard/settings/online-user">{t("OnlineUser")}</Link>
|
<Link to="/dashboard/settings/api-tokens">{t("ApiTokens")}</Link>
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="/dashboard/settings/waf" asChild>
|
|
||||||
<Link to="/dashboard/settings/waf">{t("WAF")}</Link>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -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",
|
: "[&>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,
|
defaultClassNames.caption_label,
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
|||||||
+113
-3
@@ -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 (
|
return (
|
||||||
<svg viewBox="0 0 496 512" fill="currentColor" {...props}>
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { InputHTMLAttributes, forwardRef } from "react"
|
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) => {
|
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
|||||||
animation = 0,
|
animation = 0,
|
||||||
maxCount = 3,
|
maxCount = 3,
|
||||||
modalPopover = false,
|
modalPopover = false,
|
||||||
asChild = false,
|
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { TableCell, TableHead, TableRow } from "@/components/ui/table"
|
import { TableCell, TableHead, TableRow } from "@/components/ui/table"
|
||||||
import { useMediaQuery } from "@/hooks/useMediaQuery"
|
import { useMediaQuery } from "@/hooks/useMediaQuery"
|
||||||
|
import { virtualizedTableFeatures } from "@/lib/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
Row,
|
Row,
|
||||||
|
RowData,
|
||||||
SortDirection,
|
SortDirection,
|
||||||
SortingState,
|
SortingState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
useTable,
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { HTMLAttributes, JSX, forwardRef, useEffect, useRef, useState } from "react"
|
import { HTMLAttributes, JSX, forwardRef, useEffect, useRef, useState } from "react"
|
||||||
import { TableVirtuoso } from "react-virtuoso"
|
import { TableVirtuoso } from "react-virtuoso"
|
||||||
@@ -26,7 +26,9 @@ const TableComponent = forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTab
|
|||||||
)
|
)
|
||||||
TableComponent.displayName = "TableComponent"
|
TableComponent.displayName = "TableComponent"
|
||||||
|
|
||||||
const TableRowComponent = <TData,>(rows: Row<TData>[]) =>
|
const TableRowComponent = <TData extends RowData>(
|
||||||
|
rows: Row<typeof virtualizedTableFeatures, TData>[],
|
||||||
|
) =>
|
||||||
function getTableRow(props: HTMLAttributes<HTMLTableRowElement>) {
|
function getTableRow(props: HTMLAttributes<HTMLTableRowElement>) {
|
||||||
// @ts-expect-error data-index is a valid attribute
|
// @ts-expect-error data-index is a valid attribute
|
||||||
const index = props["data-index"]
|
const index = props["data-index"]
|
||||||
@@ -64,34 +66,33 @@ function SortingIndicator({ isSorted }: { isSorted: SortDirection | false }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
interface DataTableProps<TData extends RowData> {
|
||||||
columns: ColumnDef<TData, TValue>[]
|
columns: ColumnDef<typeof virtualizedTableFeatures, TData>[]
|
||||||
data: TData[]
|
data: TData[]
|
||||||
rowComponent?: (
|
rowComponent?: (
|
||||||
rows: Row<TData>[],
|
rows: Row<typeof virtualizedTableFeatures, TData>[],
|
||||||
) => (props: HTMLAttributes<HTMLTableRowElement>) => JSX.Element | null
|
) => (props: HTMLAttributes<HTMLTableRowElement>) => JSX.Element | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData extends RowData>({
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
rowComponent,
|
rowComponent,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{
|
{
|
||||||
id: "type",
|
id: "type",
|
||||||
desc: true,
|
desc: true,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: virtualizedTableFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
},
|
},
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { rows } = table.getRowModel()
|
const { rows } = table.getRowModel()
|
||||||
|
|||||||
+58
-25
@@ -1,6 +1,6 @@
|
|||||||
import { getProfile, login as loginRequest } from "@/api/user"
|
import { getProfile, login as loginRequest } from "@/api/user"
|
||||||
import { AuthContextProps } from "@/types"
|
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 { useTranslation } from "react-i18next"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -9,78 +9,111 @@ import { useMainStore } from "./useMainStore"
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextProps>({
|
const AuthContext = createContext<AuthContextProps>({
|
||||||
profile: undefined,
|
profile: undefined,
|
||||||
|
loading: true,
|
||||||
login: () => {},
|
login: () => {},
|
||||||
loginOauth2: () => {},
|
loginOauth2: () => {},
|
||||||
logout: () => {},
|
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 }) => {
|
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const profile = useMainStore((store) => store.profile)
|
const profile = useMainStore((store) => store.profile)
|
||||||
const setProfile = useMainStore((store) => store.setProfile)
|
const setProfile = useMainStore((store) => store.setProfile)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const { t } = useTranslation()
|
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(() => {
|
useEffect(() => {
|
||||||
|
const epoch = authEpoch.current
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
user.role = user.role || 0
|
if (authEpoch.current !== epoch) return
|
||||||
|
user.role = normalizeRole(user.role)
|
||||||
setProfile(user)
|
setProfile(user)
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
|
if (authEpoch.current !== epoch) return
|
||||||
setProfile(undefined)
|
setProfile(undefined)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [])
|
}, [setProfile])
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const login = async (username: string, password: string) => {
|
const login = useCallback(
|
||||||
try {
|
async (username: string, password: string) => {
|
||||||
await loginRequest(username, password)
|
try {
|
||||||
const user = await getProfile()
|
await loginRequest(username, password)
|
||||||
user.role = user.role || 0
|
const user = await getProfile()
|
||||||
setProfile(user)
|
authEpoch.current++
|
||||||
navigate("/dashboard")
|
user.role = normalizeRole(user.role)
|
||||||
} catch (error: any) {
|
setProfile(user)
|
||||||
const msg = error?.message
|
navigate("/dashboard")
|
||||||
if (msg === "ApiErrorUnauthorized" || msg === "Unauthorized") {
|
} catch (error: any) {
|
||||||
toast(t("InvalidUsernameOrPassword"))
|
const msg = error?.message
|
||||||
} else {
|
if (msg === "ApiErrorUnauthorized" || msg === "Unauthorized") {
|
||||||
toast(msg || t("NetworkError"))
|
toast(t("InvalidUsernameOrPassword"))
|
||||||
|
} 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 {
|
try {
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
user.role = user.role || 0
|
authEpoch.current++
|
||||||
|
user.role = normalizeRole(user.role)
|
||||||
setProfile(user)
|
setProfile(user)
|
||||||
navigate("/dashboard")
|
navigate("/dashboard")
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error.message)
|
toast(error.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
window.history.replaceState({}, document.title, window.location.pathname)
|
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.split(";").forEach(function (c) {
|
||||||
document.cookie = c
|
document.cookie = c
|
||||||
.replace(/^ +/, "")
|
.replace(/^ +/, "")
|
||||||
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/")
|
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/")
|
||||||
})
|
})
|
||||||
setProfile(undefined)
|
setProfile(undefined)
|
||||||
|
setLoading(false)
|
||||||
navigate("/dashboard/login", { replace: true })
|
navigate("/dashboard/login", { replace: true })
|
||||||
}
|
}, [navigate, setProfile])
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
profile,
|
profile,
|
||||||
|
loading,
|
||||||
login,
|
login,
|
||||||
loginOauth2,
|
loginOauth2,
|
||||||
logout,
|
logout,
|
||||||
}),
|
}),
|
||||||
[profile],
|
[profile, loading, login, loginOauth2, logout],
|
||||||
)
|
)
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-14
@@ -1,19 +1,16 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useCallback, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
export function useMediaQuery(query: string) {
|
export function useMediaQuery(query: string) {
|
||||||
const [value, setValue] = useState(false)
|
const subscribe = useCallback(
|
||||||
|
(callback: () => void) => {
|
||||||
|
const result = matchMedia(query)
|
||||||
|
result.addEventListener("change", callback)
|
||||||
|
return () => result.removeEventListener("change", callback)
|
||||||
|
},
|
||||||
|
[query],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
const getSnapshot = useCallback(() => matchMedia(query).matches, [query])
|
||||||
function onChange(event: MediaQueryListEvent) {
|
|
||||||
setValue(event.matches)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = matchMedia(query)
|
return useSyncExternalStore(subscribe, getSnapshot, () => false)
|
||||||
result.addEventListener("change", onChange)
|
|
||||||
setValue(result.matches)
|
|
||||||
|
|
||||||
return () => result.removeEventListener("change", onChange)
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const NotificationProvider: React.FC<NotificationProviderProps> = ({
|
|||||||
setNotifier(undefined)
|
setNotifier(undefined)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [location.pathname])
|
}, [location.pathname, setNotifier, setNotifierGroup, withNotifier, withNotifierGroup])
|
||||||
|
|
||||||
const value: NotificationContextProps = useMemo(
|
const value: NotificationContextProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const ServerProvider: React.FC<ServerProviderProps> = ({
|
|||||||
setServer(undefined)
|
setServer(undefined)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [location.pathname])
|
}, [location.pathname, setServer, setServerGroup, withServer, withServerGroup])
|
||||||
|
|
||||||
const value: ServerContextProps = useMemo(
|
const value: ServerContextProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
+207
-58
@@ -1,62 +1,95 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
@plugin "tailwindcss-animate";
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
@custom-variant dark (&:is(.dark *));
|
||||||
:root {
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 0 0% 3.9%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 0 0% 3.9%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 0 0% 3.9%;
|
|
||||||
--primary: 0 0% 9%;
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
--secondary: 0 0% 96.1%;
|
|
||||||
--secondary-foreground: 0 0% 9%;
|
|
||||||
--muted: 0 0% 96.1%;
|
|
||||||
--muted-foreground: 0 0% 45.1%;
|
|
||||||
--accent: 0 0% 96.1%;
|
|
||||||
--accent-foreground: 0 0% 9%;
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 0 0% 89.8%;
|
|
||||||
--input: 0 0% 89.8%;
|
|
||||||
--ring: 0 0% 3.9%;
|
|
||||||
--chart-1: 12 76% 61%;
|
|
||||||
--chart-2: 173 58% 39%;
|
|
||||||
--chart-3: 197 37% 24%;
|
|
||||||
--chart-4: 43 74% 66%;
|
|
||||||
--chart-5: 27 87% 67%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
@theme inline {
|
||||||
--background: 0 0% 9%;
|
--radius-lg: var(--radius);
|
||||||
--foreground: 0 0% 98%;
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--card: 0 0% 3.9%;
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--card-foreground: 0 0% 98%;
|
|
||||||
--popover: 0 0% 3.9%;
|
--color-background: hsl(var(--background));
|
||||||
--popover-foreground: 0 0% 98%;
|
--color-foreground: hsl(var(--foreground));
|
||||||
--primary: 0 0% 98%;
|
--color-card: hsl(var(--card));
|
||||||
--primary-foreground: 0 0% 9%;
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
--secondary: 0 0% 14.9%;
|
--color-popover: hsl(var(--popover));
|
||||||
--secondary-foreground: 0 0% 98%;
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
--muted: 0 0% 14.9%;
|
--color-primary: hsl(var(--primary));
|
||||||
--muted-foreground: 0 0% 63.9%;
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
--accent: 0 0% 14.9%;
|
--color-secondary: hsl(var(--secondary));
|
||||||
--accent-foreground: 0 0% 98%;
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
--destructive: 0 62.8% 30.6%;
|
--color-muted: hsl(var(--muted));
|
||||||
--destructive-foreground: 0 0% 98%;
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
--border: 0 0% 14.9%;
|
--color-accent: hsl(var(--accent));
|
||||||
--input: 0 0% 14.9%;
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
--ring: 0 0% 83.1%;
|
--color-destructive: hsl(var(--destructive));
|
||||||
--chart-1: 220 70% 50%;
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
--chart-2: 160 60% 45%;
|
--color-border: hsl(var(--border));
|
||||||
--chart-3: 30 80% 55%;
|
--color-input: hsl(var(--input));
|
||||||
--chart-4: 280 65% 60%;
|
--color-ring: hsl(var(--ring));
|
||||||
--chart-5: 340 75% 55%;
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 3.9%;
|
||||||
|
--primary: 0 0% 9%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 96.1%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96.1%;
|
||||||
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
--accent: 0 0% 96.1%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 89.8%;
|
||||||
|
--input: 0 0% 89.8%;
|
||||||
|
--ring: 0 0% 3.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 0 0% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 0 0% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 0 0% 9%;
|
||||||
|
--secondary: 0 0% 14.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 14.9%;
|
||||||
|
--muted-foreground: 0 0% 63.9%;
|
||||||
|
--accent: 0 0% 14.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 14.9%;
|
||||||
|
--input: 0 0% 14.9%;
|
||||||
|
--ring: 0 0% 83.1%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -84,5 +117,121 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import deTranslation from "../locales/de/translation.json"
|
|||||||
import enTranslation from "../locales/en/translation.json"
|
import enTranslation from "../locales/en/translation.json"
|
||||||
import esTranslation from "../locales/es/translation.json"
|
import esTranslation from "../locales/es/translation.json"
|
||||||
import frTranslation from "../locales/fr/translation.json"
|
import frTranslation from "../locales/fr/translation.json"
|
||||||
|
import glTranslation from "../locales/gl/translation.json"
|
||||||
import idTranslation from "../locales/id/translation.json"
|
import idTranslation from "../locales/id/translation.json"
|
||||||
import itTranslation from "../locales/it/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 ruTranslation from "../locales/ru/translation.json"
|
||||||
import taTranslation from "../locales/ta/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 zhCNTranslation from "../locales/zh-CN/translation.json"
|
||||||
import zhTWTranslation from "../locales/zh-TW/translation.json"
|
import zhTWTranslation from "../locales/zh-TW/translation.json"
|
||||||
|
|
||||||
@@ -43,6 +47,18 @@ const resources = {
|
|||||||
"id-ID": {
|
"id-ID": {
|
||||||
translation: idTranslation,
|
translation: idTranslation,
|
||||||
},
|
},
|
||||||
|
"ja-JP": {
|
||||||
|
translation: jaTranslation,
|
||||||
|
},
|
||||||
|
"ro-RO": {
|
||||||
|
translation: roTranslation,
|
||||||
|
},
|
||||||
|
"uk-UA": {
|
||||||
|
translation: ukTranslation,
|
||||||
|
},
|
||||||
|
"gl-ES": {
|
||||||
|
translation: glTranslation,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStoredLanguage = () => {
|
const getStoredLanguage = () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -133,6 +133,19 @@ export function formatPath(path: string) {
|
|||||||
return path.replace(/\/{2,}/g, "/")
|
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) {
|
export function joinIP(p?: ModelIP) {
|
||||||
if (p) {
|
if (p) {
|
||||||
if (p.ipv4_addr && p.ipv6_addr) {
|
if (p.ipv4_addr && p.ipv6_addr) {
|
||||||
|
|||||||
@@ -148,6 +148,10 @@
|
|||||||
"Loading": "Laden",
|
"Loading": "Laden",
|
||||||
"Services": "Services",
|
"Services": "Services",
|
||||||
"DashboardOriginalHost": "Agent Verbindungsadresse [Domainname/IP:port]",
|
"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",
|
"NavigateTo": "Navigieren",
|
||||||
"Offline": "Offline",
|
"Offline": "Offline",
|
||||||
"Interval": "Intervall",
|
"Interval": "Intervall",
|
||||||
@@ -161,7 +165,6 @@
|
|||||||
"Login": "Anmelden",
|
"Login": "Anmelden",
|
||||||
"NewPassword": "Neues Passwort",
|
"NewPassword": "Neues Passwort",
|
||||||
"FileManager": "Pseudo File Manager",
|
"FileManager": "Pseudo File Manager",
|
||||||
"ShowInService": "Zeige in Service",
|
|
||||||
"CommunityThemeDescription": "Dieses Design wird von der Community bereitgestellt, Benutzung auf eigene Gefahr",
|
"CommunityThemeDescription": "Dieses Design wird von der Community bereitgestellt, Benutzung auf eigene Gefahr",
|
||||||
"Language": "Sprache",
|
"Language": "Sprache",
|
||||||
"FullIPNotification": "Vollständige IP-Adresse in Benachrichtigung anzeigen",
|
"FullIPNotification": "Vollständige IP-Adresse in Benachrichtigung anzeigen",
|
||||||
@@ -180,5 +183,30 @@
|
|||||||
"EmptyNote": "Du hast noch keine Notizen.",
|
"EmptyNote": "Du hast noch keine Notizen.",
|
||||||
"BackToHome": "Zurück zur Startseite",
|
"BackToHome": "Zurück zur Startseite",
|
||||||
"OnAlert": "Server mit Warnung",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@
|
|||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
"ConfirmDeletion": "Confirm Deletion?",
|
"ConfirmDeletion": "Confirm Deletion?",
|
||||||
"Services": "Services",
|
"Services": "Services",
|
||||||
"ShowInService": "Show in Service",
|
|
||||||
"Coverages": {
|
"Coverages": {
|
||||||
"Excludes": "Excludes Specific Servers",
|
"Excludes": "Excludes Specific Servers",
|
||||||
"Only": "Only Specific Servers",
|
"Only": "Only Specific Servers",
|
||||||
@@ -136,9 +135,16 @@
|
|||||||
"EditServerGroup": "Edit Server Group",
|
"EditServerGroup": "Edit Server Group",
|
||||||
"CreateServerGroup": "Create Server Group",
|
"CreateServerGroup": "Create Server Group",
|
||||||
"User": "User",
|
"User": "User",
|
||||||
|
"Owner": "Owner",
|
||||||
|
"GlobalAgent": "Global Agent",
|
||||||
|
"UnknownUser": "Unknown user (#{{id}})",
|
||||||
"WAF": "Web Application Firewall",
|
"WAF": "Web Application Firewall",
|
||||||
"SiteName": "Site Name",
|
"SiteName": "Site Name",
|
||||||
"DashboardOriginalHost": "Agent connecting address [domain name/IP:port]",
|
"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",
|
"ConfigTLS": "Use TLS to connect Agent",
|
||||||
"LoginFailed": "Login Failed",
|
"LoginFailed": "Login Failed",
|
||||||
"BruteForceAttackingToken": "Brute Force Attacking Token",
|
"BruteForceAttackingToken": "Brute Force Attacking Token",
|
||||||
@@ -157,6 +163,7 @@
|
|||||||
"DomainNotificationDays": "Domain Notification Days",
|
"DomainNotificationDays": "Domain Notification Days",
|
||||||
"ServerNotificationDays": "Server Notification Days",
|
"ServerNotificationDays": "Server Notification Days",
|
||||||
"FullIPNotification": "Show Full IP Address in Notification Messages",
|
"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",
|
"EditService": "Edit Service",
|
||||||
"CreateService": "Create Service",
|
"CreateService": "Create Service",
|
||||||
"EditTask": "Edit Task",
|
"EditTask": "Edit Task",
|
||||||
@@ -260,5 +267,60 @@
|
|||||||
"ClipboardWriteFailed": "Clipboard write failed",
|
"ClipboardWriteFailed": "Clipboard write failed",
|
||||||
"PastedFromClipboard": "Pasted from clipboard",
|
"PastedFromClipboard": "Pasted from clipboard",
|
||||||
"ClipboardReadFailed": "Clipboard read failed",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@
|
|||||||
"Coverage": "Cobertura",
|
"Coverage": "Cobertura",
|
||||||
"LocalService": "Servicio local",
|
"LocalService": "Servicio local",
|
||||||
"InstallCommands": "Comando de instalación",
|
"InstallCommands": "Comando de instalación",
|
||||||
"ShowInService": "Mostrar en servicio",
|
|
||||||
"Coverages": {
|
"Coverages": {
|
||||||
"Alarmed": "Ejecutado en el servidor que activó la alarma",
|
"Alarmed": "Ejecutado en el servidor que activó la alarma",
|
||||||
"Excludes": "Excluir servidores específicos",
|
"Excludes": "Excluir servidores específicos",
|
||||||
@@ -158,6 +157,10 @@
|
|||||||
"RequestType": "Tipo de solicitud",
|
"RequestType": "Tipo de solicitud",
|
||||||
"RequestBody": "Cuerpo de la solicitud",
|
"RequestBody": "Cuerpo de la solicitud",
|
||||||
"DashboardOriginalHost": "Dirección de conexión del Agent [nombre de dominio/IP:puerto]",
|
"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",
|
"ConfigTLS": "Usar TLS para conectar el Agent",
|
||||||
"CustomCodes": "Códigos personalizados (Style y Script)",
|
"CustomCodes": "Códigos personalizados (Style y Script)",
|
||||||
"CustomCodesDashboard": "Códigos personalizados para el Dashboard",
|
"CustomCodesDashboard": "Códigos personalizados para el Dashboard",
|
||||||
@@ -254,5 +257,29 @@
|
|||||||
"CopiedToClipboard": "Copiar al portapapeles",
|
"CopiedToClipboard": "Copiar al portapapeles",
|
||||||
"ClipboardWriteFailed": "Error al escribir en el portapapeles",
|
"ClipboardWriteFailed": "Error al escribir en el portapapeles",
|
||||||
"PastedFromClipboard": "Pegado del 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,6 @@
|
|||||||
"Confirm": "Confirmer",
|
"Confirm": "Confirmer",
|
||||||
"ConfirmDeletion": "Confirmer la suppression ?",
|
"ConfirmDeletion": "Confirmer la suppression ?",
|
||||||
"Services": "Services",
|
"Services": "Services",
|
||||||
"ShowInService": "Montrer en service",
|
|
||||||
"MaximumLatency": "Délai maximum (ms)",
|
"MaximumLatency": "Délai maximum (ms)",
|
||||||
"MinimumLatency": "Délai minimum (millisecondes)",
|
"MinimumLatency": "Délai minimum (millisecondes)",
|
||||||
"Command": "Commande",
|
"Command": "Commande",
|
||||||
@@ -92,5 +91,30 @@
|
|||||||
"UserInvalid": "L’utilisateur est invalide",
|
"UserInvalid": "L’utilisateur est invalide",
|
||||||
"BlockByUser": "Bloqué par un administrateur",
|
"BlockByUser": "Bloqué par un administrateur",
|
||||||
"OnlineUser": "Utilisateur en ligne",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,31 @@
|
|||||||
"NoRowsAreSelected": "Non hai filas seleccionadas",
|
"NoRowsAreSelected": "Non hai filas seleccionadas",
|
||||||
"ThisOperationIsUnrecoverable": "A operación non se pode desfacer!",
|
"ThisOperationIsUnrecoverable": "A operación non se pode desfacer!",
|
||||||
"TaskTriggeredSuccessfully": "A tarefa desencadeouse correctamente"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@
|
|||||||
"Confirm": "Konfirmasi",
|
"Confirm": "Konfirmasi",
|
||||||
"ConfirmDeletion": "Konfirmasi Penghapusan?",
|
"ConfirmDeletion": "Konfirmasi Penghapusan?",
|
||||||
"Services": "Layanan",
|
"Services": "Layanan",
|
||||||
"ShowInService": "Tampilkan di Layanan",
|
|
||||||
"Coverages": {
|
"Coverages": {
|
||||||
"Excludes": "Kecualikan Server Tertentu",
|
"Excludes": "Kecualikan Server Tertentu",
|
||||||
"Only": "Hanya Server Tertentu",
|
"Only": "Hanya Server Tertentu",
|
||||||
@@ -139,6 +138,10 @@
|
|||||||
"WAF": "Firewall Aplikasi Web",
|
"WAF": "Firewall Aplikasi Web",
|
||||||
"SiteName": "Nama Situs",
|
"SiteName": "Nama Situs",
|
||||||
"DashboardOriginalHost": "Alamat koneksi Agen [nama domain/IP:port]",
|
"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",
|
"ConfigTLS": "Gunakan TLS untuk menghubungkan Agen",
|
||||||
"LoginFailed": "Gagal Masuk",
|
"LoginFailed": "Gagal Masuk",
|
||||||
"BruteForceAttackingToken": "Token Serangan Brute Force",
|
"BruteForceAttackingToken": "Token Serangan Brute Force",
|
||||||
@@ -255,5 +258,62 @@
|
|||||||
"ClipboardWriteFailed": "Gagal menulis ke papan klip",
|
"ClipboardWriteFailed": "Gagal menulis ke papan klip",
|
||||||
"PastedFromClipboard": "Ditempel dari papan klip",
|
"PastedFromClipboard": "Ditempel dari papan klip",
|
||||||
"ClipboardReadFailed": "Gagal membaca 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,6 @@
|
|||||||
"Confirm": "Confermo",
|
"Confirm": "Confermo",
|
||||||
"ConfirmDeletion": "Confermi l'eliminazione?",
|
"ConfirmDeletion": "Confermi l'eliminazione?",
|
||||||
"Services": "Servizi",
|
"Services": "Servizi",
|
||||||
"ShowInService": "Mostra in servizio",
|
|
||||||
"Coverages": {
|
"Coverages": {
|
||||||
"Only": "Solo server specifici",
|
"Only": "Solo server specifici",
|
||||||
"Excludes": "Escludi server specifici",
|
"Excludes": "Escludi server specifici",
|
||||||
@@ -142,6 +141,10 @@
|
|||||||
"CustomCodes": "Codice personalizzato (stili e script)",
|
"CustomCodes": "Codice personalizzato (stili e script)",
|
||||||
"CustomCodesDashboard": "Codice personalizzato per dashboard",
|
"CustomCodesDashboard": "Codice personalizzato per dashboard",
|
||||||
"DashboardOriginalHost": "Indirizzo di ancoraggio dell'agente [nome dominio/IP:porta]",
|
"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",
|
"ConfigTLS": "Usa TLS per connettere Agent",
|
||||||
"CustomPublicDNSNameserversforDDNS": "Server dei nomi DNS pubblici personalizzati per DDNS",
|
"CustomPublicDNSNameserversforDDNS": "Server dei nomi DNS pubblici personalizzati per DDNS",
|
||||||
"WebRealIPHeader": "Intestazione della richiesta IP reale del frontend",
|
"WebRealIPHeader": "Intestazione della richiesta IP reale del frontend",
|
||||||
@@ -175,5 +178,30 @@
|
|||||||
"OnlineUser": "Utente online",
|
"OnlineUser": "Utente online",
|
||||||
"BlockIdentifier": "Identificatore del blocco",
|
"BlockIdentifier": "Identificatore del blocco",
|
||||||
"UserId": "ID utente",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"nezha": "Nezha Monitoramento"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@
|
|||||||
"Credential": "Учетные данные",
|
"Credential": "Учетные данные",
|
||||||
"CommunityThemeDescription": "Эта тема предоставлена сообществом, используйте на свой страх и риск",
|
"CommunityThemeDescription": "Эта тема предоставлена сообществом, используйте на свой страх и риск",
|
||||||
"DashboardOriginalHost": "Адрес подключения агента [домен/IP:порт]",
|
"DashboardOriginalHost": "Адрес подключения агента [домен/IP:порт]",
|
||||||
|
"DashboardHost": "Хост панели для OAuth2 callback [домен/IP:порт]",
|
||||||
|
"DashboardHostHint": "Публичный хост панели, используемый для формирования URL обратного вызова OAuth2. Укажите его, если панель доступна на домене, отличном от адреса подключения агента. Оставьте пустым, чтобы доверять Host из запроса как есть.",
|
||||||
|
"ReservedHosts": "Зарезервированные хосты (через запятую; публичные/реверс-прокси домены, которые участники не могут использовать как NAT-домен)",
|
||||||
|
"ReservedHostsHint": "За реверс-прокси панель не видит собственный публичный домен и не защищается автоматически. Укажите здесь все публичные входные хосты, иначе участник сможет зарегистрировать такой домен как NAT-домен и перехватить маршрутизацию панели.",
|
||||||
"Error": "Ошибка",
|
"Error": "Ошибка",
|
||||||
"TriggerMode": "Режим срабатывания",
|
"TriggerMode": "Режим срабатывания",
|
||||||
"Once": "Один раз",
|
"Once": "Один раз",
|
||||||
@@ -176,7 +180,6 @@
|
|||||||
"Language": "Язык",
|
"Language": "Язык",
|
||||||
"ConfirmDeletion": "Подтвердить удаление?",
|
"ConfirmDeletion": "Подтвердить удаление?",
|
||||||
"OriginalPassword": "Старый пароль",
|
"OriginalPassword": "Старый пароль",
|
||||||
"ShowInService": "Показывать в Сервисе",
|
|
||||||
"EnableFailureNotification": "Включить уведомления об ошибках",
|
"EnableFailureNotification": "Включить уведомления об ошибках",
|
||||||
"CreateNAT": "Создать NAT-конфигурацию",
|
"CreateNAT": "Создать NAT-конфигурацию",
|
||||||
"Scheduled": "Запланированные задачи",
|
"Scheduled": "Запланированные задачи",
|
||||||
@@ -185,5 +188,30 @@
|
|||||||
"IPChangeNotification": "Уведомление об изменении IP",
|
"IPChangeNotification": "Уведомление об изменении IP",
|
||||||
"CreateAlertRule": "Создать правила оповещений",
|
"CreateAlertRule": "Создать правила оповещений",
|
||||||
"UserId": "ID пользователя",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
},
|
},
|
||||||
"ConfirmDeletion": "நீக்குதலை உறுதிப்படுத்தவா?",
|
"ConfirmDeletion": "நீக்குதலை உறுதிப்படுத்தவா?",
|
||||||
"Services": "சேவைகள்",
|
"Services": "சேவைகள்",
|
||||||
"ShowInService": "சேவையில் காட்டு",
|
|
||||||
"Coverages": {
|
"Coverages": {
|
||||||
"Excludes": "குறிப்பிட்ட சேவையகங்களை விலக்குகிறது",
|
"Excludes": "குறிப்பிட்ட சேவையகங்களை விலக்குகிறது",
|
||||||
"Only": "குறிப்பிட்ட சேவையகங்கள் மட்டுமே",
|
"Only": "குறிப்பிட்ட சேவையகங்கள் மட்டுமே",
|
||||||
@@ -135,6 +134,10 @@
|
|||||||
"WAF": "வலை பயன்பாடு ஃபயர்வால்",
|
"WAF": "வலை பயன்பாடு ஃபயர்வால்",
|
||||||
"SiteName": "தளத்தின் பெயர்",
|
"SiteName": "தளத்தின் பெயர்",
|
||||||
"DashboardOriginalHost": "முகவரியை இணைக்கும் முகவரி [டொமைன் பெயர்/ஐபி: போர்ட்]",
|
"DashboardOriginalHost": "முகவரியை இணைக்கும் முகவரி [டொமைன் பெயர்/ஐபி: போர்ட்]",
|
||||||
|
"DashboardHost": "OAuth2 கால்பேக்கிற்கான டாஷ்போர்டு ஹோஸ்ட் [டொமைன் பெயர்/ஐபி: போர்ட்]",
|
||||||
|
"DashboardHostHint": "OAuth2 கால்பேக் URL-ஐ உருவாக்கப் பயன்படும் டாஷ்போர்டின் பொது ஹோஸ்ட். ஏஜெண்ட் இணைப்பு முகவரியிலிருந்து வேறுபட்ட டொமைனில் டாஷ்போர்டு அணுகப்பட்டால் இதை அமைக்கவும். கோரிக்கையின் Host-ஐ அப்படியே நம்ப காலியாக விடவும்.",
|
||||||
|
"ReservedHosts": "ஒதுக்கப்பட்ட ஹோஸ்ட்கள் (கமாவால் பிரிக்கப்பட்டது; உறுப்பினர்கள் NAT டொமைனாகப் பயன்படுத்த முடியாத பொது/ரிவர்ஸ்-ப்ராக்ஸி டொமைன்கள்)",
|
||||||
|
"ReservedHostsHint": "ரிவர்ஸ்-ப்ராக்ஸிக்குப் பின்னால் டாஷ்போர்டு அதன் சொந்த பொது டொமைனைப் பார்க்க முடியாது, எனவே அது தானாகப் பாதுகாக்கப்படாது. அனைத்து பொது நுழைவு ஹோஸ்ட்களையும் இங்கே பட்டியலிடுங்கள்; இல்லையெனில் ஒரு உறுப்பினர் அதை NAT டொமைனாகப் பதிவுசெய்து டாஷ்போர்டு வழித்தடத்தைக் கடத்தலாம்.",
|
||||||
"ConfigTLS": "முகவரை இணைக்க TLS ஐப் பயன்படுத்தவும்",
|
"ConfigTLS": "முகவரை இணைக்க TLS ஐப் பயன்படுத்தவும்",
|
||||||
"LoginFailed": "உள்நுழைவு தோல்வியடைந்தது",
|
"LoginFailed": "உள்நுழைவு தோல்வியடைந்தது",
|
||||||
"BruteForceAttackingToken": "மிருகத்தனமான ஆற்றல் டோக்கனைத் தாக்குகிறது",
|
"BruteForceAttackingToken": "மிருகத்தனமான ஆற்றல் டோக்கனைத் தாக்குகிறது",
|
||||||
@@ -180,5 +183,30 @@
|
|||||||
"ConfirmBlock": "தொகுதி உறுதிப்படுத்தவும்",
|
"ConfirmBlock": "தொகுதி உறுதிப்படுத்தவும்",
|
||||||
"RejectPassword": "கடவுச்சொல் உள்நுழைவை நிராகரிக்கவும்",
|
"RejectPassword": "கடவுச்சொல் உள்நுழைவை நிராகரிக்கவும்",
|
||||||
"EmptyText": "உரை காலியாக உள்ளது",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@
|
|||||||
"Confirm": "Підтвердити",
|
"Confirm": "Підтвердити",
|
||||||
"ConfirmDeletion": "Підтвердити видалення?",
|
"ConfirmDeletion": "Підтвердити видалення?",
|
||||||
"Services": "Сервіси",
|
"Services": "Сервіси",
|
||||||
"ShowInService": "Показувати у Сервісі",
|
|
||||||
"Coverages": {
|
"Coverages": {
|
||||||
"Excludes": "Виключити певні сервери",
|
"Excludes": "Виключити певні сервери",
|
||||||
"Only": "Тільки певні сервери",
|
"Only": "Тільки певні сервери",
|
||||||
@@ -127,5 +126,30 @@
|
|||||||
"RequestType": "Тип запиту",
|
"RequestType": "Тип запиту",
|
||||||
"RequestBody": "Тіло запиту",
|
"RequestBody": "Тіло запиту",
|
||||||
"FileManager": "Псевдо Менеджер файлів",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@
|
|||||||
"Confirm": "确认",
|
"Confirm": "确认",
|
||||||
"ConfirmDeletion": "确认删除?",
|
"ConfirmDeletion": "确认删除?",
|
||||||
"Services": "服务",
|
"Services": "服务",
|
||||||
"ShowInService": "服务中显示",
|
|
||||||
"Coverages": {
|
"Coverages": {
|
||||||
"Excludes": "排除特定服务器",
|
"Excludes": "排除特定服务器",
|
||||||
"Only": "仅特定服务器",
|
"Only": "仅特定服务器",
|
||||||
@@ -146,12 +145,19 @@
|
|||||||
"EditNotifierGroup": "编辑通知分组",
|
"EditNotifierGroup": "编辑通知分组",
|
||||||
"CreateNotifierGroup": "创建通知分组",
|
"CreateNotifierGroup": "创建通知分组",
|
||||||
"User": "用户",
|
"User": "用户",
|
||||||
|
"Owner": "所属用户",
|
||||||
|
"GlobalAgent": "全局 Agent",
|
||||||
|
"UnknownUser": "未知用户 (#{{id}})",
|
||||||
"WAF": "Web应用防火墙",
|
"WAF": "Web应用防火墙",
|
||||||
"SiteName": "站点名称",
|
"SiteName": "站点名称",
|
||||||
"Language": "语言",
|
"Language": "语言",
|
||||||
"CustomCodes": "自定义代码(样式和脚本)",
|
"CustomCodes": "自定义代码(样式和脚本)",
|
||||||
"CustomCodesDashboard": "仪表板的自定义代码",
|
"CustomCodesDashboard": "仪表板的自定义代码",
|
||||||
"DashboardOriginalHost": "Agent对接地址【域名/IP:端口】",
|
"DashboardOriginalHost": "Agent对接地址【域名/IP:端口】",
|
||||||
|
"DashboardHost": "面板 OAuth2 回调域名【域名/IP:端口】",
|
||||||
|
"DashboardHostHint": "用于生成 OAuth2 回调地址的面板对外域名。当面板访问域名与 Agent 对接地址不同时设置此项。留空则信任请求 Host,不做强制重写。",
|
||||||
|
"ReservedHosts": "保留 Host(逗号分隔;反代/公网域名,成员不可注册为 NAT 域名)",
|
||||||
|
"ReservedHostsHint": "反代部署下面板看不到自身的对外域名,无法自动保护。请在此列出所有公网入口域名,否则成员可将其注册为 NAT 域名抢占面板路由。",
|
||||||
"ConfigTLS": "Agent 使用 TLS 连接",
|
"ConfigTLS": "Agent 使用 TLS 连接",
|
||||||
"CustomPublicDNSNameserversforDDNS": "DDNS 的自定义公共 DNS 名称服务器",
|
"CustomPublicDNSNameserversforDDNS": "DDNS 的自定义公共 DNS 名称服务器",
|
||||||
"WebRealIPHeader": "前端真实IP请求头",
|
"WebRealIPHeader": "前端真实IP请求头",
|
||||||
@@ -164,6 +170,7 @@
|
|||||||
"DomainNotificationDays": "域名到期提醒天数",
|
"DomainNotificationDays": "域名到期提醒天数",
|
||||||
"ServerNotificationDays": "VPS到期提醒天数",
|
"ServerNotificationDays": "VPS到期提醒天数",
|
||||||
"FullIPNotification": "在通知消息中显示完整的 IP 地址",
|
"FullIPNotification": "在通知消息中显示完整的 IP 地址",
|
||||||
|
"EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)",
|
||||||
"LoginFailed": "登录失败",
|
"LoginFailed": "登录失败",
|
||||||
"BruteForceAttackingToken": "暴力攻击令牌",
|
"BruteForceAttackingToken": "暴力攻击令牌",
|
||||||
"BruteForceAttackingAgentSecret": "暴力攻击代理秘密",
|
"BruteForceAttackingAgentSecret": "暴力攻击代理秘密",
|
||||||
@@ -260,5 +267,58 @@
|
|||||||
"ClipboardWriteFailed": "无法写入剪贴板",
|
"ClipboardWriteFailed": "无法写入剪贴板",
|
||||||
"PastedFromClipboard": "已从剪贴板粘贴",
|
"PastedFromClipboard": "已从剪贴板粘贴",
|
||||||
"ClipboardReadFailed": "无法读取剪贴板",
|
"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": "权限"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,6 @@
|
|||||||
"Confirm": "確認",
|
"Confirm": "確認",
|
||||||
"ConfirmDeletion": "確認刪除?",
|
"ConfirmDeletion": "確認刪除?",
|
||||||
"Services": "服務",
|
"Services": "服務",
|
||||||
"ShowInService": "服務中顯示",
|
|
||||||
"Coverages": {
|
"Coverages": {
|
||||||
"Only": "僅特定伺服器",
|
"Only": "僅特定伺服器",
|
||||||
"Excludes": "排除特定伺服器",
|
"Excludes": "排除特定伺服器",
|
||||||
@@ -138,12 +137,19 @@
|
|||||||
"EditNotifierGroup": "編輯通知分組",
|
"EditNotifierGroup": "編輯通知分組",
|
||||||
"CreateNotifierGroup": "建立通知分組",
|
"CreateNotifierGroup": "建立通知分組",
|
||||||
"User": "使用者",
|
"User": "使用者",
|
||||||
|
"Owner": "所屬使用者",
|
||||||
|
"GlobalAgent": "全域 Agent",
|
||||||
|
"UnknownUser": "未知使用者 (#{{id}})",
|
||||||
"WAF": "Web應用防火牆",
|
"WAF": "Web應用防火牆",
|
||||||
"SiteName": "網站名稱",
|
"SiteName": "網站名稱",
|
||||||
"Language": "語言",
|
"Language": "語言",
|
||||||
"CustomCodes": "自訂程式碼(樣式和腳本)",
|
"CustomCodes": "自訂程式碼(樣式和腳本)",
|
||||||
"CustomCodesDashboard": "儀表板的自訂程式碼",
|
"CustomCodesDashboard": "儀表板的自訂程式碼",
|
||||||
"DashboardOriginalHost": "Agent對接位址【網域名稱/IP:連接埠】",
|
"DashboardOriginalHost": "Agent對接位址【網域名稱/IP:連接埠】",
|
||||||
|
"DashboardHost": "面板 OAuth2 回呼網域【網域名稱/IP:連接埠】",
|
||||||
|
"DashboardHostHint": "用於產生 OAuth2 回呼位址的面板對外網域。當面板存取網域與 Agent 對接位址不同時設定此項。留空則信任請求 Host,不做強制重寫。",
|
||||||
|
"ReservedHosts": "保留 Host(逗號分隔;反代/公網網域,成員不可註冊為 NAT 網域)",
|
||||||
|
"ReservedHostsHint": "反代部署下面板看不到自身的對外網域,無法自動保護。請在此列出所有公網入口網域,否則成員可將其註冊為 NAT 網域搶佔面板路由。",
|
||||||
"ConfigTLS": "Agent 使用 TLS 連線",
|
"ConfigTLS": "Agent 使用 TLS 連線",
|
||||||
"CustomPublicDNSNameserversforDDNS": "DDNS 的自訂公共 DNS 名稱伺服器",
|
"CustomPublicDNSNameserversforDDNS": "DDNS 的自訂公共 DNS 名稱伺服器",
|
||||||
"WebRealIPHeader": "前端真實IP請求頭",
|
"WebRealIPHeader": "前端真實IP請求頭",
|
||||||
@@ -186,5 +192,62 @@
|
|||||||
"Option": "選項",
|
"Option": "選項",
|
||||||
"Value": "值",
|
"Value": "值",
|
||||||
"Preview": "預覽",
|
"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": "網路錯誤"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ import ServerPage from "./routes/server"
|
|||||||
import ServerGroupPage from "./routes/server-group"
|
import ServerGroupPage from "./routes/server-group"
|
||||||
import ServicePage from "./routes/service"
|
import ServicePage from "./routes/service"
|
||||||
import SettingsPage from "./routes/settings"
|
import SettingsPage from "./routes/settings"
|
||||||
|
import TransferPage from "./routes/transfer"
|
||||||
import UserPage from "./routes/user"
|
import UserPage from "./routes/user"
|
||||||
import WAFPage from "./routes/waf"
|
import WAFPage from "./routes/waf"
|
||||||
import DomainPage from "./routes/domain"
|
import DomainPage from "./routes/domain"
|
||||||
|
import ApiTokensPage from "./routes/api-tokens"
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
@@ -143,8 +146,17 @@ const router = createBrowserRouter([
|
|||||||
path: "/dashboard/domain",
|
path: "/dashboard/domain",
|
||||||
element: <DomainPage />,
|
element: <DomainPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard/settings/api-tokens",
|
||||||
|
element: <ApiTokensPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard/transfer",
|
||||||
|
element: <TransferPage />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
])
|
])
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<RouterProvider router={router} />)
|
createRoot(document.getElementById("root")!).render(<RouterProvider router={router} />)
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelAlertRule, triggerModes } from "@/types"
|
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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -37,7 +38,7 @@ export default function AlertRulePage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<ModelAlertRule>[] = [
|
const columns: ColumnDef<typeof selectableTableFeatures, ModelAlertRule>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -57,7 +58,6 @@ export default function AlertRulePage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,10 +131,10 @@ export default function AlertRulePage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
@@ -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
@@ -14,9 +14,10 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { IconButton } from "@/components/xui/icon-button"
|
import { IconButton } from "@/components/xui/icon-button"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelCron } from "@/types"
|
import { ModelCron } from "@/types"
|
||||||
import { cronTypes } 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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -36,7 +37,7 @@ export default function CronPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<ModelCron>[] = [
|
const columns: ColumnDef<typeof selectableTableFeatures, ModelCron>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -56,7 +57,6 @@ export default function CronPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -199,10 +199,10 @@ export default function CronPage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
+5
-5
@@ -12,8 +12,9 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelDDNSProfile } from "@/types"
|
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 { useEffect, useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -45,7 +46,7 @@ export default function DDNSPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<ModelDDNSProfile>[] = [
|
const columns: ColumnDef<typeof selectableTableFeatures, ModelDDNSProfile>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -65,7 +66,6 @@ export default function DDNSPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,10 +136,10 @@ export default function DDNSPage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form"
|
} from "@/components/ui/form"
|
||||||
import { GitHubIcon } from "@/components/ui/icon"
|
import { OAuthProviderIcon } from "@/components/ui/icon"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
@@ -39,7 +39,7 @@ function Login() {
|
|||||||
if (oauth2) {
|
if (oauth2) {
|
||||||
loginOauth2()
|
loginOauth2()
|
||||||
}
|
}
|
||||||
}, [window.location.search])
|
}, [loginOauth2])
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -56,7 +56,7 @@ function Login() {
|
|||||||
async function loginWith(provider: string) {
|
async function loginWith(provider: string) {
|
||||||
try {
|
try {
|
||||||
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.LOGIN)
|
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.LOGIN)
|
||||||
window.location.href = redirectUrl.redirect!
|
window.location.assign(redirectUrl.redirect!)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message)
|
toast.error(error.message)
|
||||||
}
|
}
|
||||||
@@ -120,10 +120,11 @@ function Login() {
|
|||||||
<div className="mt-3 flex flex-col gap-3">
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
{settingData?.config?.oauth2_providers?.map((p: string) => (
|
{settingData?.config?.oauth2_providers?.map((p: string) => (
|
||||||
<Button
|
<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"
|
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)}
|
onClick={() => loginWith(p)}
|
||||||
>
|
>
|
||||||
{p === "GitHub" && <GitHubIcon className="size-4" />}
|
<OAuthProviderIcon provider={p} className="size-4" />
|
||||||
{p}
|
{p}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+5
-5
@@ -12,8 +12,9 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelNAT } from "@/types"
|
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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -33,7 +34,7 @@ export default function NATPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<ModelNAT>[] = [
|
const columns: ColumnDef<typeof selectableTableFeatures, ModelNAT>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -53,7 +54,6 @@ export default function NATPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,10 +119,10 @@ export default function NATPage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
@@ -13,13 +13,19 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelNotificationGroupResponseItem } from "@/types"
|
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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import useSWR from "swr"
|
import useSWR from "swr"
|
||||||
|
|
||||||
|
type NotificationGroupColumn = ColumnDef<
|
||||||
|
typeof selectableTableFeatures,
|
||||||
|
ModelNotificationGroupResponseItem
|
||||||
|
>
|
||||||
|
|
||||||
export default function NotificationGroupPage() {
|
export default function NotificationGroupPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data, mutate, error, isLoading } = useSWR<ModelNotificationGroupResponseItem[]>(
|
const { data, mutate, error, isLoading } = useSWR<ModelNotificationGroupResponseItem[]>(
|
||||||
@@ -37,7 +43,7 @@ export default function NotificationGroupPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<ModelNotificationGroupResponseItem>[] = [
|
const columns: NotificationGroupColumn[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -57,7 +63,6 @@ export default function NotificationGroupPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -112,10 +117,10 @@ export default function NotificationGroupPage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { useNotification } from "@/hooks/useNotfication"
|
import { useNotification } from "@/hooks/useNotfication"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelNotification } from "@/types"
|
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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -40,7 +41,7 @@ export default function NotificationPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<ModelNotification>[] = [
|
const columns: ColumnDef<typeof selectableTableFeatures, ModelNotification>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -60,7 +61,6 @@ export default function NotificationPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,10 +127,10 @@ export default function NotificationPage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
|
|
||||||
|
|
||||||
import { GithubComNezhahqNezhaModelPaginatedResponseArrayModelOnlineUserModelOnlineUser, ModelOnlineUser } from "@/types"
|
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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
@@ -55,7 +59,7 @@ export default function OnlineUserPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
let columns: ColumnDef<ModelOnlineUser>[] = [
|
let columns: ColumnDef<typeof selectableTableFeatures, ModelOnlineUser>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -75,7 +79,6 @@ export default function OnlineUserPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -128,10 +131,10 @@ export default function OnlineUserPage() {
|
|||||||
return data?.data?.value ?? []
|
return data?.data?.value ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable<ModelOnlineUser>({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
+11
-3
@@ -4,6 +4,7 @@ import { ProfileCard } from "@/components/profile"
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { OAuthProviderIcon } from "@/components/ui/icon"
|
||||||
import { useMainStore } from "@/hooks/useMainStore"
|
import { useMainStore } from "@/hooks/useMainStore"
|
||||||
import { useMediaQuery } from "@/hooks/useMediaQuery"
|
import { useMediaQuery } from "@/hooks/useMediaQuery"
|
||||||
import { useServer } from "@/hooks/useServer"
|
import { useServer } from "@/hooks/useServer"
|
||||||
@@ -26,12 +27,12 @@ export default function ProfilePage() {
|
|||||||
})
|
})
|
||||||
window.history.replaceState({}, document.title, window.location.pathname)
|
window.history.replaceState({}, document.title, window.location.pathname)
|
||||||
}
|
}
|
||||||
}, [window.location.search])
|
}, [setProfile])
|
||||||
|
|
||||||
const bindO2 = async (provider: string) => {
|
const bindO2 = async (provider: string) => {
|
||||||
try {
|
try {
|
||||||
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.BIND)
|
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.BIND)
|
||||||
window.location.href = redirectUrl.redirect!
|
window.location.assign(redirectUrl.redirect!)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message)
|
toast.error(error.message)
|
||||||
}
|
}
|
||||||
@@ -107,8 +108,15 @@ export default function ProfilePage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-lg font-semibold">
|
<CardContent className="text-lg font-semibold">
|
||||||
{settingData?.config?.oauth2_providers?.map((provider) => (
|
{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">
|
<section className="flex gap-2 items-center">
|
||||||
|
<OAuthProviderIcon
|
||||||
|
provider={provider}
|
||||||
|
className="size-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
<p>{provider}: </p>
|
<p>{provider}: </p>
|
||||||
{profile.oauth2_bind?.[provider.toLowerCase()] && (
|
{profile.oauth2_bind?.[provider.toLowerCase()] && (
|
||||||
<p className=" bg-muted px-1.5 py-0.5 text-sm rounded-full">
|
<p className=" bg-muted px-1.5 py-0.5 text-sm rounded-full">
|
||||||
|
|||||||
+29
-9
@@ -1,16 +1,36 @@
|
|||||||
import { useAuth } from "@/hooks/useAuth"
|
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 }) => {
|
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { profile } = useAuth()
|
const { profile, loading } = useAuth()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
if (!profile && window.location.pathname !== "/dashboard/login") {
|
// While AuthProvider's initial getProfile() round-trip is in flight we
|
||||||
return (
|
// can't yet decide between "render protected subtree" and "redirect to
|
||||||
<>
|
// login". Two key invariants:
|
||||||
<Navigate to="/dashboard/login" />
|
// - For the login page itself, render children so the user can log in
|
||||||
{children}
|
// 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
|
return children
|
||||||
|
|||||||
+6
-4
@@ -22,6 +22,12 @@ export default function Root() {
|
|||||||
}
|
}
|
||||||
}, [settingData?.config?.custom_code_dashboard])
|
}, [settingData?.config?.custom_code_dashboard])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingData?.config?.language && !localStorage.getItem("language")) {
|
||||||
|
i18n.changeLanguage(settingData?.config?.language)
|
||||||
|
}
|
||||||
|
}, [settingData?.config?.language])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -30,10 +36,6 @@ export default function Root() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingData?.config?.language && !localStorage.getItem("language")) {
|
|
||||||
i18n.changeLanguage(settingData?.config?.language)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelServerGroupResponseItem } from "@/types"
|
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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -37,7 +38,7 @@ export default function ServerGroupPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<ModelServerGroupResponseItem>[] = [
|
const columns: ColumnDef<typeof selectableTableFeatures, ModelServerGroupResponseItem>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -57,7 +58,6 @@ export default function ServerGroupPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -112,10 +112,10 @@ export default function ServerGroupPage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
+32
-5
@@ -20,9 +20,10 @@ import {
|
|||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { IconButton } from "@/components/xui/icon-button"
|
import { IconButton } from "@/components/xui/icon-button"
|
||||||
import { useServer } from "@/hooks/useServer"
|
import { useServer } from "@/hooks/useServer"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { joinIP } from "@/lib/utils"
|
import { joinIP } from "@/lib/utils"
|
||||||
import { ModelServerTaskResponse, ModelServer as Server } from "@/types"
|
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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -43,7 +44,7 @@ export default function ServerPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Server>[]>(
|
const columns = useMemo<ColumnDef<typeof selectableTableFeatures, Server>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
@@ -64,7 +65,6 @@ export default function ServerPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,6 +81,33 @@ export default function ServerPage() {
|
|||||||
return <div className="max-w-24 whitespace-normal break-words">{s.name}</div>
|
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"),
|
header: t("Group"),
|
||||||
accessorKey: "groups",
|
accessorKey: "groups",
|
||||||
@@ -161,10 +188,10 @@ export default function ServerPage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelService as Service } from "@/types"
|
import { ModelService as Service } from "@/types"
|
||||||
import { serviceTypes } 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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -34,7 +35,7 @@ export default function ServicePage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<Service>[] = [
|
const columns: ColumnDef<typeof selectableTableFeatures, Service>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -54,7 +55,6 @@ export default function ServicePage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -165,10 +165,10 @@ export default function ServicePage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
+97
-43
@@ -7,6 +7,7 @@ import { Combobox } from "@/components/ui/combobox"
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -25,13 +26,13 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
import { useNotification } from "@/hooks/useNotfication"
|
import { useNotification } from "@/hooks/useNotfication"
|
||||||
import useSetting from "@/hooks/useSetting"
|
import useSetting from "@/hooks/useSetting"
|
||||||
import { asOptionalField } from "@/lib/utils"
|
import { asOptionalField, safeExternalHref } from "@/lib/utils"
|
||||||
import { nezhaLang, settingCoverageTypes } from "@/types"
|
import { nezhaLang, settingCoverageTypes } from "@/types"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { Navigate } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
@@ -44,6 +45,8 @@ const settingFormSchema = z.object({
|
|||||||
language: z.string().min(2),
|
language: z.string().min(2),
|
||||||
user_template: z.string().min(1),
|
user_template: z.string().min(1),
|
||||||
install_host: asOptionalField(z.string()),
|
install_host: asOptionalField(z.string()),
|
||||||
|
dashboard_host: asOptionalField(z.string()),
|
||||||
|
reserved_hosts: asOptionalField(z.string()),
|
||||||
custom_code: asOptionalField(z.string()),
|
custom_code: asOptionalField(z.string()),
|
||||||
custom_code_dashboard: asOptionalField(z.string()),
|
custom_code_dashboard: asOptionalField(z.string()),
|
||||||
web_real_ip_header: 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_ip_change_notification: asOptionalField(z.boolean()),
|
||||||
enable_plain_ip_in_notification: asOptionalField(z.boolean()),
|
enable_plain_ip_in_notification: asOptionalField(z.boolean()),
|
||||||
custom_logo: asOptionalField(z.string()),
|
custom_logo: asOptionalField(z.string()),
|
||||||
|
|
||||||
custom_description: asOptionalField(z.string()),
|
custom_description: asOptionalField(z.string()),
|
||||||
custom_links: asOptionalField(z.string()),
|
custom_links: asOptionalField(z.string()),
|
||||||
background_image_day: asOptionalField(z.string()),
|
background_image_day: asOptionalField(z.string()),
|
||||||
@@ -66,13 +70,14 @@ const settingFormSchema = z.object({
|
|||||||
domain_expiry_notification_days: asOptionalField(z.string()),
|
domain_expiry_notification_days: asOptionalField(z.string()),
|
||||||
server_expiry_notification_days: asOptionalField(z.string()),
|
server_expiry_notification_days: asOptionalField(z.string()),
|
||||||
expiry_notification_group_id: z.coerce.number().int().min(0),
|
expiry_notification_group_id: z.coerce.number().int().min(0),
|
||||||
|
enable_mcp: asOptionalField(z.boolean()),
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { data: config, mutate } = useSetting()
|
const { data: config, mutate } = useSetting()
|
||||||
const { profile } = useAuth()
|
const { profile, loading: authLoading } = useAuth()
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const { notifierGroup } = useNotification()
|
const { notifierGroup } = useNotification()
|
||||||
const ngroupList = notifierGroup?.map((ng) => ({
|
const ngroupList = notifierGroup?.map((ng) => ({
|
||||||
@@ -82,12 +87,10 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const isAdmin = profile?.role === 0
|
const isAdmin = profile?.role === 0
|
||||||
|
|
||||||
if (!isAdmin) {
|
// 所有 hooks 必须在条件 return 之前调用,否则违反 rules-of-hooks。
|
||||||
navigate("/dashboard/settings/online-user")
|
const form = useForm<z.infer<typeof settingFormSchema>>({
|
||||||
}
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(settingFormSchema) as any,
|
resolver: zodResolver(settingFormSchema) as any,
|
||||||
|
|
||||||
defaultValues: config
|
defaultValues: config
|
||||||
? {
|
? {
|
||||||
...config.config,
|
...config.config,
|
||||||
@@ -117,6 +120,13 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [config?.config, form])
|
}, [config?.config, form])
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <Navigate to="/dashboard/settings/api-tokens" replace />
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async (values: any) => {
|
const onSubmit = async (values: any) => {
|
||||||
try {
|
try {
|
||||||
await updateSettings(values)
|
await updateSettings(values)
|
||||||
@@ -129,19 +139,19 @@ export default function SettingsPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
} finally {
|
|
||||||
if (values.language != i18n.language) {
|
|
||||||
i18n.changeLanguage(values.language)
|
|
||||||
}
|
|
||||||
toast(t("Success"))
|
|
||||||
}
|
}
|
||||||
|
if (values.language != i18n.language) {
|
||||||
|
i18n.changeLanguage(values.language)
|
||||||
|
}
|
||||||
|
toast(t("Success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<SettingsTab className="mt-6 mb-4 w-full" />
|
<SettingsTab className="mt-6 mb-4 w-full" />
|
||||||
<div>
|
<div>
|
||||||
<Form {...form}>
|
<Form {...(form as any)}>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -390,14 +400,24 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<div className="px-8 py-1">
|
<div className="px-8 py-1">
|
||||||
<a
|
{safeExternalHref(
|
||||||
href={template.repository}
|
template.repository,
|
||||||
target="_blank"
|
) ? (
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
href={safeExternalHref(
|
||||||
>
|
template.repository,
|
||||||
{template.repository}
|
)}
|
||||||
</a>
|
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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -459,6 +479,34 @@ export default function SettingsPage() {
|
|||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="tls"
|
name="tls"
|
||||||
@@ -512,16 +560,10 @@ export default function SettingsPage() {
|
|||||||
checked={field.value == "NZ::Use-Peer-IP"}
|
checked={field.value == "NZ::Use-Peer-IP"}
|
||||||
className="ml-2"
|
className="ml-2"
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
if (checked) {
|
form.setValue(
|
||||||
field.disabled = true
|
"web_real_ip_header",
|
||||||
form.setValue(
|
checked ? "NZ::Use-Peer-IP" : "",
|
||||||
"web_real_ip_header",
|
)
|
||||||
"NZ::Use-Peer-IP",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
field.disabled = false
|
|
||||||
form.setValue("web_real_ip_header", "")
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormLabel className="font-normal ml-2">
|
<FormLabel className="font-normal ml-2">
|
||||||
@@ -551,16 +593,10 @@ export default function SettingsPage() {
|
|||||||
checked={field.value == "NZ::Use-Peer-IP"}
|
checked={field.value == "NZ::Use-Peer-IP"}
|
||||||
className="ml-2"
|
className="ml-2"
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
if (checked) {
|
form.setValue(
|
||||||
field.disabled = true
|
"agent_real_ip_header",
|
||||||
form.setValue(
|
checked ? "NZ::Use-Peer-IP" : "",
|
||||||
"agent_real_ip_header",
|
)
|
||||||
"NZ::Use-Peer-IP",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
field.disabled = false
|
|
||||||
form.setValue("agent_real_ip_header", "")
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormLabel className="font-normal ml-2">
|
<FormLabel className="font-normal ml-2">
|
||||||
@@ -749,6 +785,24 @@ export default function SettingsPage() {
|
|||||||
</FormItem>
|
</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>
|
<Button type="submit">{t("Confirm")}</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -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
@@ -13,8 +13,9 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { UserCard } from "@/components/user"
|
import { UserCard } from "@/components/user"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import { ModelUser } from "@/types"
|
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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -34,7 +35,7 @@ export default function UserPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const columns: ColumnDef<ModelUser>[] = [
|
const columns: ColumnDef<typeof selectableTableFeatures, ModelUser>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -54,7 +55,6 @@ export default function UserPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,10 +105,10 @@ export default function UserPage() {
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
+5
-5
@@ -22,13 +22,14 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
import { selectableTableFeatures } from "@/lib/table"
|
||||||
import {
|
import {
|
||||||
GithubComNezhahqNezhaModelValueArrayModelWAFApiMock,
|
GithubComNezhahqNezhaModelValueArrayModelWAFApiMock,
|
||||||
ModelWAFApiMock,
|
ModelWAFApiMock,
|
||||||
wafBlockIdentifiers,
|
wafBlockIdentifiers,
|
||||||
wafBlockReasons,
|
wafBlockReasons,
|
||||||
} from "@/types"
|
} 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 { useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
@@ -61,7 +62,7 @@ export default function WAFPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
let columns: ColumnDef<ModelWAFApiMock>[] = [
|
let columns: ColumnDef<typeof selectableTableFeatures, ModelWAFApiMock>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
@@ -81,7 +82,6 @@ export default function WAFPage() {
|
|||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -147,10 +147,10 @@ export default function WAFPage() {
|
|||||||
return data?.value ?? []
|
return data?.value ?? []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: selectableTableFeatures,
|
||||||
data: dataCache,
|
data: dataCache,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { ModelProfile } from "@/types"
|
|||||||
|
|
||||||
export interface AuthContextProps {
|
export interface AuthContextProps {
|
||||||
profile: ModelProfile | undefined
|
profile: ModelProfile | undefined
|
||||||
|
loading: boolean
|
||||||
login: (username: string, password: string) => void
|
login: (username: string, password: string) => void
|
||||||
loginOauth2: () => void
|
loginOauth2: () => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user