chore: merge upstream/main into nezha-dash-v1 preserving custom domain and styling features

This commit is contained in:
Bot
2026-08-31 02:59:30 +08:00
105 changed files with 9974 additions and 3076 deletions
+25
View File
@@ -0,0 +1,25 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions" # GitHub Actions
directory: "/"
schedule:
interval: "daily"
allow:
- dependency-type: "all"
groups:
all:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
all:
patterns:
- "*"
+14 -8
View File
@@ -12,32 +12,38 @@ jobs:
contents: write
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Bun
uses: oven-sh/setup-bun@v1
- name: Set up pnpm
uses: pnpm/action-setup@v6
- name: Set up Node.js
uses: actions/setup-node@v7
with:
bun-version: "latest"
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: bun install
run: pnpm install --frozen-lockfile
- name: Build static export
run: |
bun run build
pnpm run build
- name: Compress dist folder
run: zip -r dist.zip dist
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
draft: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }}
files: dist.zip
- name: Changelog
run: bun x changelogithub
run: pnpm dlx changelogithub
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
@@ -1,52 +1,60 @@
name: Auto Fix Lint and Format
on:
pull_request_target:
pull_request:
types: [opened, synchronize]
permissions:
contents: write
pull-requests: write
jobs:
auto-fix:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Bun
uses: oven-sh/setup-bun@v1
- name: Set up pnpm
uses: pnpm/action-setup@v6
- name: Set up Node.js
uses: actions/setup-node@v7
with:
bun-version: "latest"
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: bun install
run: pnpm install --frozen-lockfile
- name: Run linter and fix issues
run: bun run lint:fix
run: pnpm run lint:fix
- name: Run formatter
run: bun run format
run: pnpm run format
- name: Check for changes
id: check_changes
run: |
git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
git diff --exit-code || echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Commit and push changes
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
if: steps.check_changes.outputs.has_changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: auto-fix linting and formatting issues"
commit_options: "--no-verify"
file_pattern: "."
- name: Add PR comment
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: actions/github-script@v7
if: steps.check_changes.outputs.has_changes == 'true'
uses: actions/github-script@v9
with:
github-token: ${{secrets.GITHUB_TOKEN}}
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
+10 -5
View File
@@ -18,21 +18,26 @@ jobs:
contents: read
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9
uses: pnpm/action-setup@v6
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run quality checks
run: pnpm run check
- name: Run tests
run: pnpm run test:coverage
- name: Run build
run: pnpm run build
+1
View File
@@ -12,6 +12,7 @@ package-lock.json
dist
dist-ssr
dev-dist
coverage
*.local
# Editor directories and files
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
-709
View File
@@ -1,709 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "nazha-dashboard-vite",
"dependencies": {
"@fontsource/inter": "5.1.1",
"@heroicons/react": "2.2.0",
"@radix-ui/react-accordion": "1.2.3",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.6",
"@radix-ui/react-label": "2.1.2",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-progress": "1.1.2",
"@radix-ui/react-select": "2.1.6",
"@radix-ui/react-separator": "1.1.2",
"@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-switch": "1.1.3",
"@radix-ui/react-tooltip": "1.1.8",
"@tanstack/react-query": "5.66.7",
"@tanstack/react-query-devtools": "5.66.7",
"@tanstack/react-table": "8.21.2",
"@types/d3-geo": "3.1.0",
"@types/luxon": "3.4.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"country-flag-icons": "1.5.18",
"d3-geo": "3.1.1",
"dayjs": "1.11.13",
"framer-motion": "12.23.26",
"i18n-iso-countries": "7.14.0",
"i18next": "24.2.2",
"lucide-react": "0.460.0",
"luxon": "3.5.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "15.4.1",
"react-router-dom": "^7.13.0",
"recharts": "2.15.1",
"sonner": "1.7.4",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "^1.0.7",
},
"devDependencies": {
"@biomejs/biome": "2.3.10",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "22.13.4",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react-swc": "3.8.0",
"globals": "15.15.0",
"postcss": "8.5.3",
"tailwindcss": "^4.1.18",
"typescript": "~5.6.3",
"vite": "6.4.1",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/runtime": ["@babel/runtime@7.26.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw=="],
"@biomejs/biome": ["@biomejs/biome@2.3.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.10", "@biomejs/cli-darwin-x64": "2.3.10", "@biomejs/cli-linux-arm64": "2.3.10", "@biomejs/cli-linux-arm64-musl": "2.3.10", "@biomejs/cli-linux-x64": "2.3.10", "@biomejs/cli-linux-x64-musl": "2.3.10", "@biomejs/cli-win32-arm64": "2.3.10", "@biomejs/cli-win32-x64": "2.3.10" }, "bin": { "biome": "bin/biome" } }, "sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@floating-ui/core": ["@floating-ui/core@1.6.8", "", { "dependencies": { "@floating-ui/utils": "^0.2.8" } }, "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA=="],
"@floating-ui/dom": ["@floating-ui/dom@1.6.11", "", { "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.8" } }, "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.8", "", {}, "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig=="],
"@fontsource/inter": ["@fontsource/inter@5.1.1", "", {}, "sha512-weN3E+rq0Xb3Z93VHJ+Rc7WOQX9ETJPTAJ+gDcaMHtjft67L58sfS65rAjC5tZUXQ2FdZ/V1/sSzCwZ6v05kJw=="],
"@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.5", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collapsible": "1.1.3", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-RIQ15mrcvqIkDARJeERSuXSry2N8uYnxkdDetpfmalT/+0ntOXLkFOsh9iwlAsCv+qcmhZjbdJogIm6WBa6c4A=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-menu": "2.1.6", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.2", "", { "dependencies": { "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.1.6", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@swc/core": ["@swc/core@1.10.15", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.17" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.10.15", "@swc/core-darwin-x64": "1.10.15", "@swc/core-linux-arm-gnueabihf": "1.10.15", "@swc/core-linux-arm64-gnu": "1.10.15", "@swc/core-linux-arm64-musl": "1.10.15", "@swc/core-linux-x64-gnu": "1.10.15", "@swc/core-linux-x64-musl": "1.10.15", "@swc/core-win32-arm64-msvc": "1.10.15", "@swc/core-win32-ia32-msvc": "1.10.15", "@swc/core-win32-x64-msvc": "1.10.15" }, "peerDependencies": { "@swc/helpers": "*" }, "optionalPeers": ["@swc/helpers"] }, "sha512-/iFeQuNaGdK7mfJbQcObhAhsMqLT7qgMYl7jX2GEIO+VDTejESpzAyKwaMeYXExN8D6e5BRHBCe7M5YlsuzjDA=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.10.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zFdZ6/yHqMCPk7OhLFqHy/MQ1EqJhcZMpNHd1gXYT7VRU3FaqvvKETrUlG3VYl65McPC7AhMRfXPyJ0JO/jARQ=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.10.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-8g4yiQwbr8fxOOjKXdot0dEkE5zgE8uNZudLy/ZyAhiwiZ8pbJ8/wVrDOu6dqbX7FBXAoDnvZ7fwN1jk4C8jdA=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.10.15", "", { "os": "linux", "cpu": "arm" }, "sha512-rl+eVOltl2+7WXOnvmWBpMgh6aO13G5x0U0g8hjwlmD6ku3Y9iRcThpOhm7IytMEarUp5pQxItNoPq+VUGjVHg=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.10.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-qxWEQeyAJMWJqjaN4hi58WMpPdt3Tn0biSK9CYRegQtvZWCbewr6v2agtSu5AZ2rudeH6OfCWAMDQQeSgn6PJQ=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.10.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-QcELd9/+HjZx0WCxRrKcyKGWTiQ0485kFb5w8waxcSNd0d9Lgk4EFfWWVyvIb5gIHpDQmhrgzI/yRaWQX4YSZQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.10.15", "", { "os": "linux", "cpu": "x64" }, "sha512-S1+ZEEn3+a/MiMeQqQypbwTGoBG8/sPoCvpNbk+uValyygT+jSn3U0xVr45FbukpmMB+NhBMqfedMLqKA0QnJA=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.10.15", "", { "os": "linux", "cpu": "x64" }, "sha512-qW+H9g/2zTJ4jP7NDw4VAALY0ZlNEKzYsEoSj/HKi7k3tYEHjMzsxjfsY9I8WZCft23bBdV3RTCPoxCshaj1CQ=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.10.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-AhRB11aA6LxjIqut+mg7qsu/7soQDmbK6MKR9nP3hgBszpqtXbRba58lr24xIbBCMr+dpo6kgEapWt+t5Po6Zg=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.10.15", "", { "os": "win32", "cpu": "ia32" }, "sha512-UGdh430TQwbDn6KjgvRTg1fO022sbQ4yCCHUev0+5B8uoBwi9a89qAz3emy2m56C8TXxUoihW9Y9OMfaRwPXUw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.10.15", "", { "os": "win32", "cpu": "x64" }, "sha512-XJzBCqO1m929qbJsOG7FZXQWX26TnEoMctS3QjuCoyBmkHxxQmZsy78KjMes1aomTcKHCyFYgrRGWgVmk7tT4Q=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.17", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.66.4", "", {}, "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.65.0", "", {}, "sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.66.7", "", { "dependencies": { "@tanstack/query-core": "5.66.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-qd3q/tUpF2K1xItfPZddk1k/8pSXnovg41XyCqJgPoyYEirMBtB0sVEVVQ/CsAOngzgWtBPXimVf4q4kM9uO6A=="],
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.66.7", "", { "dependencies": { "@tanstack/query-devtools": "5.65.0" }, "peerDependencies": { "@tanstack/react-query": "^5.66.7", "react": "^18 || ^19" } }, "sha512-40z4PPkz06tYIF0vwLZZIZfZxKUH4OAaBOR14blCFyYm6hlU6qc+M82mkZ+D00HcEMhV7P4XeJiEuDhFq0q9Qw=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.2", "", { "dependencies": { "@tanstack/table-core": "8.21.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.2", "", {}, "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.0", "", {}, "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="],
"@types/d3-scale": ["@types/d3-scale@4.0.8", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ=="],
"@types/d3-shape": ["@types/d3-shape@3.1.6", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA=="],
"@types/d3-time": ["@types/d3-time@3.0.3", "", {}, "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.14", "", {}, "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg=="],
"@types/luxon": ["@types/luxon@3.4.2", "", {}, "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA=="],
"@types/node": ["@types/node@22.13.4", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg=="],
"@types/react": ["@types/react@19.0.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g=="],
"@types/react-dom": ["@types/react-dom@19.0.4", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.8.0", "", { "dependencies": { "@swc/core": "^1.10.15" }, "peerDependencies": { "vite": "^4 || ^5 || ^6" } }, "sha512-T4sHPvS+DIqDP51ifPqa9XIRAz/kIvIi8oXcnOZZgHmMotgmmdxe/DD5tMFlt5nuIRzT0/QuiwmKlH0503Aapw=="],
"aria-hidden": ["aria-hidden@1.2.4", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"country-flag-icons": ["country-flag-icons@1.5.18", "", {}, "sha512-z+Uzesi8u8IdkViqqbzzbkf3+a7WJpcET5B7sPwTg7GXqPYpVEgNlZ/FC3l8KO4mEf+mNkmzKLppKTN4PlCJEQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"diacritics": ["diacritics@1.3.0", "", {}, "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"fast-equals": ["fast-equals@5.0.1", "", {}, "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"framer-motion": ["framer-motion@12.23.26", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"i18n-iso-countries": ["i18n-iso-countries@7.14.0", "", { "dependencies": { "diacritics": "1.3.0" } }, "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg=="],
"i18next": ["i18next@24.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lucide-react": ["lucide-react@0.460.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg=="],
"luxon": ["luxon@3.5.0", "", {}, "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
"nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="],
"react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="],
"react-i18next": ["react-i18next@15.4.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"recharts": ["recharts@2.15.1", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
"sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tslib": ["tslib@2.8.0", "", {}, "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA=="],
"typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog/@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-dismissable-layer/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-focus-scope/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="],
"@radix-ui/react-menu/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="],
"@radix-ui/react-menu/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="],
"@radix-ui/react-menu/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
"@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="],
"@radix-ui/react-popover/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="],
"@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="],
"@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-select/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="],
"@radix-ui/react-select/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="],
"@radix-ui/react-select/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="],
"@radix-ui/react-select/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
"@radix-ui/react-tooltip/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="],
"@radix-ui/react-tooltip/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
"@radix-ui/react-use-effect-event/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-escape-keydown/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.0", "", { "bundled": true }, "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA=="],
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@radix-ui/react-dialog/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-menu/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="],
"@radix-ui/react-popover/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="],
"@radix-ui/react-tooltip/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="],
}
}
+32 -77
View File
@@ -1,94 +1,49 @@
<!doctype html>
<html>
<html lang="en">
<head>
<script>
// 在页面渲染前就执行主题初始化
try {
const storageKey = "vite-ui-theme"
let theme = localStorage.getItem(storageKey)
if (theme === "system" || !theme) {
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
}
document.documentElement.classList.add(theme)
} catch (_e) {
document.documentElement.classList.add("light")
}
</script>
<style>
/* Prevent FOUC in Safari */
html:not(.dark):not(.light) * {
visibility: hidden;
}
:root {
color-scheme: light;
--bg: #ffffff;
}
html.dark {
color-scheme: dark;
--bg: #242424;
}
html.light {
color-scheme: light;
--bg: #ffffff;
}
html {
background-color: var(--bg);
}
body {
background-color: var(--bg);
}
#root {
background-color: var(--bg);
visibility: hidden;
}
#root.loaded {
visibility: visible;
animation: fadein 0.2s;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
<script>
;(() => {
const storageKey = "vite-ui-theme"
const theme = localStorage.getItem(storageKey) || "system"
const root = document.documentElement
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
function updateThemeColor(isDark) {
const themeColor = isDark ? "#242424" : "#fafafa"
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
const resolveTheme = (theme) =>
theme === "system" ? (mediaQuery.matches ? "dark" : "light") : theme
function updateThemeColor(nextTheme) {
const themeColor = nextTheme === "dark" ? "hsl(30 15% 8%)" : "hsl(0 0% 98%)"
let meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
meta = document.createElement("meta")
meta.setAttribute("name", "theme-color")
document.head.appendChild(meta)
}
function setTheme(newTheme) {
meta.setAttribute("content", themeColor)
}
function applyResolvedTheme(resolvedTheme) {
root.classList.remove("light", "dark")
root.classList.add(newTheme)
updateThemeColor(newTheme === "dark")
root.classList.add(resolvedTheme)
root.style.colorScheme = resolvedTheme
root.style.backgroundColor =
resolvedTheme === "dark" ? "hsl(30 15% 8%)" : "hsl(0 0% 98%)"
updateThemeColor(resolvedTheme)
}
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
setTheme(systemTheme)
let theme = "system"
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
setTheme(e.matches ? "dark" : "light")
})
} else {
setTheme(theme)
try {
const storedTheme = localStorage.getItem(storageKey)
if (storedTheme === "light" || storedTheme === "dark" || storedTheme === "system") {
theme = storedTheme
}
} catch (_e) {
theme = "system"
}
applyResolvedTheme(resolveTheme(theme))
// Add loaded class after React has mounted
window.addEventListener("load", () => {
+62 -43
View File
@@ -3,68 +3,87 @@
"private": true,
"version": "2.0.2",
"type": "module",
"engines": {
"node": ">=22",
"pnpm": ">=11"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"dev:mock": "node scripts/dev-mock.js",
"mock:server": "node scripts/mock-nezha-server.js",
"typecheck": "tsc -b",
"build": "pnpm run typecheck && vite build",
"lint": "biome lint",
"lint:fix": "biome lint --fix",
"format": "biome format --write .",
"check": "biome check",
"check:fix": "biome check --fix --unsafe",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"verify": "pnpm run check && pnpm run test:coverage && pnpm run build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "5.1.1",
"@fontsource/inter": "5.2.8",
"@heroicons/react": "2.2.0",
"@number-flow/react": "0.5.5",
"@radix-ui/react-accordion": "1.2.3",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.6",
"@radix-ui/react-label": "2.1.2",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-progress": "1.1.2",
"@radix-ui/react-select": "2.1.6",
"@radix-ui/react-separator": "1.1.2",
"@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-switch": "1.1.3",
"@radix-ui/react-tooltip": "1.1.8",
"@tanstack/react-query": "5.66.7",
"@tanstack/react-query-devtools": "5.66.7",
"@tanstack/react-table": "8.21.2",
"@radix-ui/react-accordion": "1.2.13",
"@radix-ui/react-checkbox": "1.3.4",
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-dropdown-menu": "2.1.17",
"@radix-ui/react-label": "2.1.9",
"@radix-ui/react-popover": "1.1.16",
"@radix-ui/react-progress": "1.1.9",
"@radix-ui/react-select": "2.3.0",
"@radix-ui/react-separator": "1.1.9",
"@radix-ui/react-slot": "1.2.5",
"@radix-ui/react-switch": "1.3.0",
"@radix-ui/react-tooltip": "1.2.9",
"@tanstack/react-query": "5.101.0",
"@tanstack/react-query-devtools": "5.101.0",
"@tanstack/react-table": "8.21.3",
"@types/d3-geo": "3.1.0",
"@types/luxon": "3.4.2",
"@types/luxon": "3.7.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"country-flag-icons": "1.5.18",
"country-flag-icons": "1.6.17",
"d3-geo": "3.1.1",
"dayjs": "1.11.13",
"framer-motion": "12.23.26",
"dayjs": "1.11.21",
"i18n-iso-countries": "7.14.0",
"i18next": "24.2.2",
"lucide-react": "0.460.0",
"luxon": "3.5.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "15.4.1",
"react-router-dom": "^7.13.2",
"recharts": "2.15.1",
"sonner": "1.7.4",
"tailwind-merge": "2.6.0",
"i18next": "26.3.1",
"lucide-react": "1.17.0",
"luxon": "3.7.2",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-i18next": "17.0.8",
"react-router-dom": "^7.18.1",
"recharts": "3.8.1",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@biomejs/biome": "2.3.10",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "22.13.4",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "^6.0.1",
"globals": "15.15.0",
"postcss": "8.5.3",
"tailwindcss": "^4.2.2",
"typescript": "~5.6.3",
"vite": "8.0.3"
}
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "25.9.2",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.9",
"globals": "17.6.0",
"jsdom": "^29.1.1",
"postcss": "8.5.15",
"tailwindcss": "^4.3.2",
"typescript": "^7.0.2",
"vite": "^8.1.3",
"vitest": "^4.1.9",
"ws": "^8.21.0"
},
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485"
}
+2214 -1298
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
import { spawn } from "node:child_process";
const isWindows = process.platform === "win32";
const pnpmCommand = isWindows ? "pnpm.cmd" : "pnpm";
const children = [
spawn(process.execPath, ["scripts/mock-nezha-server.js"], {
stdio: "inherit",
}),
spawn(pnpmCommand, ["dev"], {
stdio: "inherit",
}),
];
let shuttingDown = false;
function shutdown(exitCode = 0) {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) {
if (!child.killed) {
child.kill("SIGTERM");
}
}
process.exit(exitCode);
}
for (const child of children) {
child.on("exit", (code, signal) => {
if (shuttingDown) return;
if (code === 0 || signal === "SIGTERM") return;
shutdown(code ?? 1);
});
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
+298
View File
@@ -0,0 +1,298 @@
import http from "node:http";
import { URL } from "node:url";
import { WebSocket, WebSocketServer } from "ws";
const port = Number(process.env.MOCK_NEZHA_PORT || 8008);
const serverCount = Number(process.env.MOCK_NEZHA_COUNT || 3000);
const updateIntervalMs = Number(
process.env.MOCK_NEZHA_INTERVAL_MS || (serverCount > 10_000 ? 0 : 2000),
);
const countries = [
"US",
"SG",
"JP",
"DE",
"GB",
"FR",
"NL",
"CA",
"AU",
"BR",
"IN",
"KR",
"HK",
"TW",
"FI",
"PL",
];
const platforms = ["linux", "debian", "ubuntu", "centos", "alpine", "Windows"];
const nowIso = () => new Date().toISOString();
function createServer(index) {
const id = index + 1;
const platform = platforms[index % platforms.length];
const memTotal = 2 ** 30 * (2 + (index % 15));
const diskTotal = 2 ** 30 * (40 + (index % 200));
const swapTotal = 2 ** 30 * (index % 8);
const memUsed = memTotal * ((index % 90) / 100);
const diskUsed = diskTotal * ((index % 84) / 100);
const swapUsed = swapTotal ? swapTotal * ((index % 60) / 100) : 0;
const isOffline = id % 17 === 0;
return {
id,
name: `mock-server-${String(id).padStart(4, "0")}`,
public_note: id % 19 === 0 ? "[[Plan]] bandwidth=1Gbps" : "",
last_active: isOffline
? new Date(Date.now() - 10 * 60 * 1000).toISOString()
: nowIso(),
country_code: countries[index % countries.length],
host: {
platform,
platform_version: platform === "Windows" ? "Server 2022" : "6.8",
cpu: [`Mock CPU ${1 + (index % 8)} Core`],
gpu: index % 40 === 0 ? ["Mock GPU"] : [],
mem_total: memTotal,
disk_total: diskTotal,
swap_total: swapTotal,
arch: index % 5 === 0 ? "arm64" : "amd64",
boot_time: Math.floor(Date.now() / 1000) - 86_400 * (1 + (index % 365)),
version: "mock-1.0.0",
},
state: {
cpu: (index * 7) % 100,
mem_used: memUsed,
swap_used: swapUsed,
disk_used: diskUsed,
net_in_transfer: 2 ** 30 * (index % 500),
net_out_transfer: 2 ** 30 * ((index * 2) % 500),
net_in_speed: 1024 * (20 + ((index * 13) % 80_000)),
net_out_speed: 1024 * (20 + ((index * 17) % 80_000)),
uptime: 3600 * (1 + (index % 20_000)),
load_1: Number(((index % 100) / 20).toFixed(2)),
load_5: Number(((index % 80) / 20).toFixed(2)),
load_15: Number(((index % 60) / 20).toFixed(2)),
tcp_conn_count: index % 300,
udp_conn_count: index % 80,
process_count: 40 + (index % 240),
temperatures: [],
gpu: index % 40 === 0 ? [(index * 3) % 100] : [],
},
};
}
const servers = Array.from({ length: serverCount }, (_, index) =>
createServer(index),
);
let onlineCount = servers.filter((server) => server.id % 17 !== 0).length;
function updateServerMetrics() {
const now = Date.now();
let nextOnlineCount = 0;
for (const server of servers) {
const isOffline = server.id % 17 === 0;
if (!isOffline) {
server.last_active = new Date(now).toISOString();
nextOnlineCount += 1;
}
const wave = (now / 1000 + server.id) % 100;
server.state.cpu = Number(wave.toFixed(2));
server.state.net_in_speed =
1024 * (20 + ((server.id * 13 + now / 1000) % 80_000));
server.state.net_out_speed =
1024 * (20 + ((server.id * 17 + now / 1000) % 80_000));
server.state.net_in_transfer += server.state.net_in_speed;
server.state.net_out_transfer += server.state.net_out_speed;
}
onlineCount = nextOnlineCount;
}
function websocketPayload() {
const payload = { now: Date.now() };
if (onlineCount > 0) {
payload.online = onlineCount;
}
if (servers.length > 0) {
payload.servers = servers;
}
return JSON.stringify(payload);
}
function jsonResponse(response, data) {
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
});
response.end(JSON.stringify(data));
}
const populatedServerGroups = [
{
group: {
id: 1,
created_at: nowIso(),
updated_at: nowIso(),
name: "Region US/EU",
},
servers:
serverCount > 10_000
? []
: servers
.filter((server) =>
["US", "DE", "GB", "FR", "NL"].includes(server.country_code),
)
.map((server) => server.id),
},
{
group: {
id: 2,
created_at: nowIso(),
updated_at: nowIso(),
name: "Region APAC",
},
servers:
serverCount > 10_000
? []
: servers
.filter((server) =>
["SG", "JP", "KR", "HK", "TW", "AU"].includes(
server.country_code,
),
)
.map((server) => server.id),
},
];
const serverGroups = serverCount === 0 ? [] : populatedServerGroups;
const serviceHistory = Array.from({ length: 30 }, (_, index) => ({
up: 95 + (index % 6),
down: index % 13 === 0 ? 1 : 0,
delay: 20 + index * 3,
}));
const services =
serverCount === 0
? {}
: {
mock_http: {
service_name: "Mock HTTP",
current_up: 1,
current_down: 0,
total_up: 2999,
total_down: 1,
delay: serviceHistory.map((item) => item.delay),
up: serviceHistory.map((item) => item.up),
down: serviceHistory.map((item) => item.down),
},
};
const httpServer = http.createServer((request, response) => {
const requestUrl = new URL(
request.url || "/",
`http://${request.headers.host}`,
);
if (request.method === "OPTIONS") {
response.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, X-CSRF-Token",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
});
response.end();
return;
}
switch (requestUrl.pathname) {
case "/api/v1/setting":
jsonResponse(response, {
success: true,
data: {
config: {
debug: true,
language: "en-US",
site_name: `Nezha Mock ${serverCount}`,
user_template: "",
admin_template: "",
custom_code: "",
},
version: "mock-1.0.0",
},
});
return;
case "/api/v1/server-group":
jsonResponse(response, { success: true, data: serverGroups });
return;
case "/api/v1/service":
jsonResponse(response, {
success: true,
data: {
services,
cycle_transfer_stats: {},
},
});
return;
case "/api/v1/profile":
jsonResponse(response, {
success: true,
data: {
id: 1,
username: "mock-user",
password: "",
created_at: nowIso(),
updated_at: nowIso(),
},
});
return;
default:
jsonResponse(response, { success: true, data: [] });
}
});
const websocketServer = new WebSocketServer({ noServer: true });
httpServer.on("upgrade", (request, socket, head) => {
if (!request.url?.startsWith("/api/v1/ws/server")) {
socket.destroy();
return;
}
websocketServer.handleUpgrade(request, socket, head, (websocket) => {
websocketServer.emit("connection", websocket, request);
});
});
websocketServer.on("connection", (websocket) => {
websocket.send(websocketPayload());
});
if (updateIntervalMs > 0) {
setInterval(() => {
updateServerMetrics();
const payload = websocketPayload();
for (const client of websocketServer.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}, updateIntervalMs);
}
httpServer.listen(port, () => {
console.log(
`Mock Nezha API listening on http://localhost:${port} with ${serverCount} servers`,
);
if (updateIntervalMs === 0) {
console.log(
"Mock Nezha websocket is in static mode. Set MOCK_NEZHA_INTERVAL_MS to enable repeated full-payload updates.",
);
}
});
+37 -13
View File
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { DateTime } from "luxon";
import type React from "react";
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Route, BrowserRouter as Router, Routes } from "react-router-dom";
@@ -14,26 +14,38 @@ import { InjectContext } from "./lib/inject";
import { fetchSetting } from "./lib/nezha-api";
import { cn } from "./lib/utils";
import ErrorPage from "./pages/ErrorPage";
import NotFound from "./pages/NotFound";
import Server from "./pages/Server";
import ServerDetail from "./pages/ServerDetail";
const NotFound = lazy(() => import("./pages/NotFound"));
const loadServerDetail = () => import("./pages/ServerDetail");
const ServerDetail = lazy(loadServerDetail);
// Route checker component
const RouteChecker: React.FC = () => {
return <MainApp />;
};
const toError = (error: unknown) => {
if (!error) return null;
return error instanceof Error ? error : new Error(String(error));
};
const MainApp: React.FC = () => {
const { data: settingData, error } = useQuery({
queryKey: ["setting"],
queryFn: () => fetchSetting(),
refetchOnMount: true,
refetchOnWindowFocus: true,
retry: false,
});
const { i18n } = useTranslation();
const [isCustomCodeInjected, setIsCustomCodeInjected] = useState(false);
const { backgroundImage: customBackgroundImage } = useBackground();
useEffect(() => {
loadServerDetail();
}, []);
useEffect(() => {
const updateConfig = () => {
const config = settingData?.data?.config;
@@ -43,6 +55,7 @@ const MainApp: React.FC = () => {
setIsCustomCodeInjected(true);
}
// 同步自定义配置到全局变量
if (config.custom_logo) window.CustomLogo = config.custom_logo;
if (config.custom_description)
@@ -66,13 +79,7 @@ const MainApp: React.FC = () => {
return () => clearInterval(interval);
}, [settingData]);
if (error) {
return <ErrorPage code={500} message={error.message} />;
}
if (!settingData) {
return null;
}
const initialBackendError = !settingData ? toError(error) : null;
if (settingData?.data?.config?.custom_code && !isCustomCodeInjected) {
return null;
@@ -130,10 +137,27 @@ const MainApp: React.FC = () => {
<Header />
<DashCommand />
<Routes>
<Route path="/" element={<Server />} />
<Route path="/server/:id" element={<ServerDetail />} />
<Route
path="/"
element={<Server backendError={initialBackendError} />}
/>
<Route
path="/server/:id"
element={
<Suspense fallback={null}>
<ServerDetail />
</Suspense>
}
/>
<Route path="/error" element={<ErrorPage />} />
<Route path="*" element={<NotFound />} />
<Route
path="*"
element={
<Suspense fallback={null}>
<NotFound />
</Suspense>
}
/>
</Routes>
<Footer />
</main>
+26 -3
View File
@@ -40,13 +40,36 @@ export const CycleTransferStatsCard: React.FC<CycleTransferStatsProps> = ({
return null;
}
// Support per-server max values (map) or single max value (number)
const max =
typeof cycleData.max === "object" && cycleData.max !== null
? cycleData.max[serverId]
: cycleData.max;
// Support per-server from values (map) or single from value (string)
const from =
typeof cycleData.from === "object" && cycleData.from !== null
? cycleData.from[serverId]
: cycleData.from;
// Support per-server to values (map) or single to value (string)
const to =
typeof cycleData.to === "object" && cycleData.to !== null
? cycleData.to[serverId]
: cycleData.to;
// Skip rendering when required fields are missing or would cause invalid display
if (!max || !from || !to) {
return null;
}
return (
<CycleTransferStatsClient
key={`${cycleId}-${serverId}`}
name={cycleData.name}
from={cycleData.from}
to={cycleData.to}
max={cycleData.max}
from={from}
to={to}
max={max}
serverStats={[
{
serverId,
+25 -12
View File
@@ -1,7 +1,7 @@
"use client";
import { Home, Moon, Sun, SunMoon } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
@@ -16,8 +16,8 @@ import {
import { useCommand } from "@/hooks/use-command";
import { useTheme } from "@/hooks/use-theme";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { saveMainPageScrollPosition } from "@/lib/navigation";
import { formatNezhaInfo } from "@/lib/utils";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
export function DashCommand() {
const { isOpen, closeCommand, toggleCommand } = useCommand();
@@ -26,11 +26,9 @@ export function DashCommand() {
const { t } = useTranslation();
const { setTheme } = useTheme();
const { lastMessage, connected } = useWebSocketContext();
const { lastData, connected } = useWebSocketContext();
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const nezhaWsData = lastData;
useEffect(() => {
const down = (e: KeyboardEvent) => {
@@ -44,9 +42,9 @@ export function DashCommand() {
return () => document.removeEventListener("keydown", down);
}, [toggleCommand]);
if (!connected || !nezhaWsData) return null;
const shortcuts = [
const shortcuts = useMemo(
() =>
[
{
keywords: ["home", "homepage"],
icon: <Home />,
@@ -74,7 +72,21 @@ export function DashCommand() {
].map((item) => ({
...item,
value: `${item.keywords.join(" ")} ${item.label}`,
})),
[navigate, setTheme, t],
);
const serverCommands = useMemo(() => {
if (!isOpen || !nezhaWsData) return [];
return (nezhaWsData.servers ?? []).map((server) => ({
id: server.id,
name: server.name,
online: formatNezhaInfo(nezhaWsData.now, server).online,
}));
}, [isOpen, nezhaWsData]);
if (!connected || !nezhaWsData) return null;
return (
<CommandDialog open={isOpen} onOpenChange={closeCommand}>
@@ -85,18 +97,19 @@ export function DashCommand() {
/>
<CommandList className="border-t">
<CommandEmpty>{t("NoResults")}</CommandEmpty>
{nezhaWsData.servers && nezhaWsData.servers.length > 0 && (
{serverCommands.length > 0 && (
<CommandGroup heading={t("Servers")}>
{nezhaWsData.servers.map((server) => (
{serverCommands.map((server) => (
<CommandItem
key={server.id}
value={server.name}
onSelect={() => {
saveMainPageScrollPosition();
navigate(`/server/${server.id}`);
closeCommand();
}}
>
{formatNezhaInfo(nezhaWsData.now, server).online ? (
{server.online ? (
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500 self-center" />
) : (
<span className="h-2 w-2 shrink-0 rounded-full bg-red-500 self-center" />
+1
View File
@@ -12,6 +12,7 @@ const Footer: React.FC = () => {
queryFn: () => fetchSetting(),
refetchOnMount: true,
refetchOnWindowFocus: true,
retry: false,
});
return (
+70 -51
View File
@@ -1,4 +1,5 @@
import { geoEquirectangular, geoPath } from "d3-geo";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import useTooltip from "@/hooks/use-tooltip";
import { geoJsonString } from "@/lib/geo-json-string";
@@ -8,6 +9,28 @@ import type { NezhaServer } from "@/types/nezha-api";
import MapTooltip from "./MapTooltip";
const geoJson = JSON.parse(geoJsonString) as {
features: Array<{
type: "Feature";
properties: {
iso_a2_eh: string;
iso_a3_eh: string;
[key: string]: string;
};
geometry: never;
}>;
};
const filteredFeatures = geoJson.features.filter(
(feature) => feature.properties.iso_a3_eh !== "",
);
type TooltipServer = {
id: number;
name: string;
status: boolean;
};
export default function GlobalMap({
serverList,
now,
@@ -16,33 +39,39 @@ export default function GlobalMap({
now: number;
}) {
const { t } = useTranslation();
const { countryList, countryServers, serverCounts } = useMemo(() => {
const countryList: string[] = [];
const serverCounts: { [key: string]: number } = {};
const countryServers: Record<string, TooltipServer[]> = {};
const serverCounts: Record<string, number> = {};
serverList.forEach((server) => {
if (!server.country_code) return;
const countryCode = server.country_code.toUpperCase();
if (!countryServers[countryCode]) {
countryList.push(countryCode);
countryServers[countryCode] = [];
}
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1;
countryServers[countryCode].push({
id: server.id,
name: server.name,
status: formatNezhaInfo(now, server).online,
});
});
return { countryList, countryServers, serverCounts };
}, [now, serverList]);
const customBackgroundImage =
(window.CustomBackgroundImage as string) !== ""
? window.CustomBackgroundImage
: undefined;
serverList.forEach((server) => {
if (server.country_code) {
const countryCode = server.country_code.toUpperCase();
if (!countryList.includes(countryCode)) {
countryList.push(countryCode);
}
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1;
}
});
const width = 900;
const height = 500;
const geoJson = JSON.parse(geoJsonString);
const filteredFeatures = geoJson.features.filter(
(feature: { properties: { iso_a3_eh: string } }) =>
feature.properties.iso_a3_eh !== "",
);
return (
<section
className={cn("flex flex-col gap-4 mt-8", {
@@ -59,8 +88,7 @@ export default function GlobalMap({
width={width}
height={height}
filteredFeatures={filteredFeatures}
nezhaServerList={serverList}
now={now}
countryServers={countryServers}
/>
</div>
</section>
@@ -75,13 +103,13 @@ interface InteractiveMapProps {
filteredFeatures: {
type: "Feature";
properties: {
iso_a3_eh: string;
iso_a2_eh: string;
[key: string]: string;
};
geometry: never;
}[];
nezhaServerList: NezhaServer[];
now: number;
countryServers: Record<string, TooltipServer[]>;
}
export function InteractiveMap({
@@ -90,17 +118,31 @@ export function InteractiveMap({
width,
height,
filteredFeatures,
nezhaServerList,
now,
countryServers,
}: InteractiveMapProps) {
const { setTooltipData } = useTooltip();
const { path, projection } = useMemo(() => {
const projection = geoEquirectangular()
.scale(140)
.translate([width / 2, height / 2])
.rotate([-12, 0, 0]);
const path = geoPath().projection(projection);
return {
path: geoPath().projection(projection),
projection,
};
}, [height, width]);
const featureCountryCodes = useMemo(
() =>
new Set(filteredFeatures.map((feature) => feature.properties.iso_a2_eh)),
[filteredFeatures],
);
const highlightedCountryCodes = useMemo(
() => new Set(countries),
[countries],
);
return (
<div
@@ -130,7 +172,7 @@ export function InteractiveMap({
onMouseEnter={() => setTooltipData(null)}
/>
{filteredFeatures.map((feature, index) => {
const isHighlighted = countries.includes(
const isHighlighted = highlightedCountryCodes.has(
feature.properties.iso_a2_eh,
);
@@ -152,21 +194,11 @@ export function InteractiveMap({
}
if (path.centroid(feature)) {
const countryCode = feature.properties.iso_a2_eh;
const countryServers = nezhaServerList
.filter(
(server: NezhaServer) =>
server.country_code?.toUpperCase() === countryCode,
)
.map((server: NezhaServer) => ({
id: server.id,
name: server.name,
status: formatNezhaInfo(now, server).online,
}));
setTooltipData({
centroid: path.centroid(feature),
country: feature.properties.name,
count: serverCount,
servers: countryServers,
servers: countryServers[countryCode] ?? [],
});
}
}}
@@ -177,9 +209,7 @@ export function InteractiveMap({
{/* 渲染不在 filteredFeatures 中的国家标记点 */}
{countries.map((countryCode) => {
// 检查该国家是否已经在 filteredFeatures 中
const isInFilteredFeatures = filteredFeatures.some(
(feature) => feature.properties.iso_a2_eh === countryCode,
);
const isInFilteredFeatures = featureCountryCodes.has(countryCode);
// 如果已经在 filteredFeatures 中,跳过
if (isInFilteredFeatures) return null;
@@ -196,22 +226,11 @@ export function InteractiveMap({
<g
key={countryCode}
onMouseEnter={() => {
const countryServers = nezhaServerList
.filter(
(server: NezhaServer) =>
server.country_code?.toUpperCase() ===
countryCode.toUpperCase(),
)
.map((server: NezhaServer) => ({
id: server.id,
name: server.name,
status: formatNezhaInfo(now, server).online,
}));
setTooltipData({
centroid: [x, y],
country: coords.name,
count: serverCount,
servers: countryServers,
servers: countryServers[countryCode] ?? [],
});
}}
className="cursor-pointer"
+53 -24
View File
@@ -1,5 +1,6 @@
import { m } from "framer-motion";
import { createRef, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { cn } from "@/lib/utils";
export default function GroupSwitch({
@@ -11,13 +12,20 @@ export default function GroupSwitch({
currentTab: string;
setCurrentTab: (tab: string) => void;
}) {
const { t } = useTranslation();
const customBackgroundImage =
(window.CustomBackgroundImage as string) !== ""
? window.CustomBackgroundImage
: undefined;
const scrollRef = useRef<HTMLDivElement>(null);
const tagRefs = useRef(tabs.map(() => createRef<HTMLDivElement>()));
const {
containerRef,
enableIndicatorAnimation,
indicator,
itemRefs,
setItemRef,
} = useActiveIndicator(tabs, currentTab);
useEffect(() => {
const container = scrollRef.current;
@@ -50,16 +58,25 @@ export default function GroupSwitch({
}, [tabs, setCurrentTab]);
useEffect(() => {
const currentTagRef = tagRefs.current[tabs.indexOf(currentTab)];
const currentTagRef = itemRefs.current[tabs.indexOf(currentTab)];
const scrollContainer = scrollRef.current;
if (currentTagRef?.current) {
currentTagRef.current.scrollIntoView({
if (currentTagRef && scrollContainer) {
const nextScrollLeft =
currentTagRef.offsetLeft -
scrollContainer.clientWidth / 2 +
currentTagRef.offsetWidth / 2;
if (typeof scrollContainer.scrollTo === "function") {
scrollContainer.scrollTo({
behavior: "smooth",
block: "nearest",
inline: "center",
left: Math.max(0, nextScrollLeft),
});
} else {
scrollContainer.scrollLeft = Math.max(0, nextScrollLeft);
}
}, [currentTab, tabs.indexOf]);
}
}, [currentTab, itemRefs, tabs]);
if (tabs.length === 1 && tabs[0] === "All") {
return null;
@@ -71,37 +88,49 @@ export default function GroupSwitch({
className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"
>
<div
ref={containerRef}
className={cn(
"flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
"relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
{
"bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage,
},
)}
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 46,
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{tabs.map((tab: string, index: number) => (
<div
key={tab}
ref={tagRefs.current[index]}
onClick={() => setCurrentTab(tab)}
ref={setItemRef(index)}
onClick={() => {
if (currentTab !== tab) {
enableIndicatorAnimation();
}
setCurrentTab(tab);
}}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-semibold transition-all duration-500",
"relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50",
currentTab === tab
? "text-black dark:text-white"
: "text-stone-400 dark:text-stone-500",
)}
>
{currentTab === tab && (
<m.div
layoutId="tab-switch"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{tab}</p>
<p className="whitespace-nowrap">
{tab === "All" ? t("group.all", { defaultValue: "All" }) : tab}
</p>
</div>
</div>
))}
+31 -19
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { AnimatePresence, m } from "framer-motion";
import { ImageMinus } from "lucide-react";
import { DateTime } from "luxon";
import { useEffect, useRef, useState } from "react";
@@ -16,6 +15,7 @@ import { cn } from "@/lib/utils";
import AnimateCountClient from "./AnimatedCount";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { Loader, LoadingSpinner } from "./loading/Loader";
import NumericText from "./NumericText";
import { SearchButton } from "./SearchButton";
import { Button } from "./ui/button";
@@ -58,15 +58,12 @@ function Header() {
queryFn: () => fetchSetting(),
refetchOnMount: true,
refetchOnWindowFocus: true,
retry: false,
});
const { lastMessage, connected } = useWebSocketContext();
const { lastData, connected } = useWebSocketContext();
const onlineCount = connected
? lastMessage
? JSON.parse(lastMessage.data).online || 0
: 0
: "...";
const onlineCount = connected ? (lastData ? lastData.online || 0 : 0) : "...";
const siteName = settingData?.data?.config?.site_name;
@@ -179,7 +176,11 @@ function Header() {
},
)}
>
{connected ? onlineCount : <Loader visible={true} />}
{connected ? (
<NumericText value={onlineCount} />
) : (
<Loader visible={true} />
)}
<p className="text-muted-foreground">
{connected ? t("online") : t("offline")}
</p>
@@ -205,10 +206,29 @@ type links = {
name: string;
};
function parseCustomLinks(customLinks: string | undefined): links[] | null {
if (!customLinks) return null;
try {
const parsedLinks = JSON.parse(customLinks);
if (!Array.isArray(parsedLinks)) {
return null;
}
return parsedLinks.filter(
(link): link is links =>
typeof link?.link === "string" && typeof link?.name === "string",
);
} catch {
return null;
}
}
function Links() {
const customLinks = window.CustomLinks as string;
const links: links[] | null = customLinks ? JSON.parse(customLinks) : null;
const links = parseCustomLinks(customLinks);
if (!links) return null;
@@ -249,20 +269,12 @@ export function RefreshToast() {
}
return (
<AnimatePresence>
<m.div
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
transition={{ type: "spring", duration: 0.8 }}
className="fixed left-1/2 -translate-x-1/2 top-8 z-999 flex items-center justify-between gap-4 rounded-[50px] border border-solid bg-white px-2 py-1.5 shadow-xl shadow-black/5 dark:border-stone-700 dark:bg-stone-800 dark:shadow-none"
>
<div className="refresh-toast-animate fixed left-1/2 -translate-x-1/2 top-8 z-999 flex items-center justify-between gap-4 rounded-[50px] border border-solid bg-white px-2 py-1.5 shadow-xl shadow-black/5 dark:border-stone-700 dark:bg-stone-800 dark:shadow-none">
<section className="flex items-center gap-1.5">
<LoadingSpinner />
<p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
</section>
</m.div>
</AnimatePresence>
</div>
);
}
+14 -11
View File
@@ -1,8 +1,8 @@
import { AnimatePresence, m } from "framer-motion";
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import useTooltip from "@/hooks/use-tooltip";
import { saveMainPageScrollPosition } from "@/lib/navigation";
const MapTooltip = memo(function MapTooltip() {
const { t } = useTranslation();
@@ -11,18 +11,22 @@ const MapTooltip = memo(function MapTooltip() {
if (!tooltipData) return null;
const verticalPlacement =
tooltipData.centroid[1] < 120
? "translate(20%, 8px)"
: tooltipData.centroid[1] > 380
? "translate(20%, calc(-100% - 8px))"
: "translate(20%, -50%)";
return (
<AnimatePresence mode="wait">
<m.div
initial={{ opacity: 0, filter: "blur(10px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(10px)" }}
className="absolute hidden lg:block bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700 z-50"
<div
className="tooltip-animate absolute hidden lg:block bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700 z-50"
data-testid="map-tooltip"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(20%, -50%)",
transform: verticalPlacement,
}}
onMouseEnter={(e) => {
e.stopPropagation();
@@ -51,7 +55,7 @@ const MapTooltip = memo(function MapTooltip() {
type="button"
className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
onClick={() => {
sessionStorage.setItem("fromMainPage", "true");
saveMainPageScrollPosition();
navigate(`/server/${server.id}`);
}}
>
@@ -62,8 +66,7 @@ const MapTooltip = memo(function MapTooltip() {
</button>
))}
</div>
</m.div>
</AnimatePresence>
</div>
);
});
+87 -20
View File
@@ -1,7 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { m } from "framer-motion";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as React from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
@@ -34,6 +33,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import {
fetchLoginUser,
fetchMonitor,
@@ -41,7 +41,6 @@ import {
} from "@/lib/nezha-api";
import { cn, formatTime } from "@/lib/utils";
import type { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api";
import NetworkChartLoading from "./NetworkChartLoading";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
@@ -51,6 +50,8 @@ interface ResultItem {
[key: string]: number;
}
const MIN_PERIOD_LOADING_MS = 500;
/**
* Helper method to calculate packet loss from delay data
*/
@@ -148,10 +149,11 @@ export function NetworkChart({
}
}, [isLogin, period]);
const { data: monitorData } = useQuery({
const { data: monitorData, isPlaceholderData } = useQuery({
queryKey: ["monitor", server_id, period],
queryFn: () => fetchMonitor(server_id, period),
enabled: show,
placeholderData: keepPreviousData,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchInterval: 10000,
@@ -215,6 +217,7 @@ export function NetworkChart({
chartData={transformedData}
serverName={monitorData.data[0].server_name}
formattedData={formattedData}
isPeriodLoading={isPlaceholderData}
period={period}
onPeriodChange={setPeriod}
isLogin={isLogin}
@@ -228,6 +231,7 @@ export const NetworkChartClient = React.memo(function NetworkChart({
chartData,
serverName,
formattedData,
isPeriodLoading,
period,
onPeriodChange,
isLogin,
@@ -237,17 +241,59 @@ export const NetworkChartClient = React.memo(function NetworkChart({
chartData: ServerMonitorChart;
serverName: string;
formattedData: ResultItem[];
isPeriodLoading: boolean;
period: MonitorPeriod;
onPeriodChange: (period: MonitorPeriod) => void;
isLogin: boolean;
}) {
const { t } = useTranslation();
const [showPeriodLoading, setShowPeriodLoading] = React.useState(false);
const loadingStartedAtRef = React.useRef<number | null>(null);
const TIME_RANGE_OPTIONS: { value: MonitorPeriod; label: string }[] = [
const TIME_RANGE_OPTIONS = useMemo<{ value: MonitorPeriod; label: string }[]>(
() => [
{ value: "1d", label: t("monitor.period1d") },
{ value: "7d", label: t("monitor.period7d") },
{ value: "30d", label: t("monitor.period30d") },
];
],
[t],
);
const timeRangeValues = useMemo(
() => TIME_RANGE_OPTIONS.map((option) => option.value),
[TIME_RANGE_OPTIONS],
);
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(timeRangeValues, period);
React.useEffect(() => {
let timeoutId: number | undefined;
if (isPeriodLoading) {
loadingStartedAtRef.current = Date.now();
setShowPeriodLoading(true);
return;
}
const loadingStartedAt = loadingStartedAtRef.current;
if (loadingStartedAt === null) {
setShowPeriodLoading(false);
return;
}
const elapsed = Date.now() - loadingStartedAt;
const remaining = Math.max(0, MIN_PERIOD_LOADING_MS - elapsed);
timeoutId = window.setTimeout(() => {
setShowPeriodLoading(false);
loadingStartedAtRef.current = null;
}, remaining);
return () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, [isPeriodLoading]);
const customBackgroundImage =
(window.CustomBackgroundImage as string) !== ""
@@ -544,13 +590,33 @@ export const NetworkChartClient = React.memo(function NetworkChart({
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3 sm:-mt-5 -mt-3 flex-wrap">
<TooltipProvider delayDuration={120}>
<div className="flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border">
{TIME_RANGE_OPTIONS.map((option) => {
<div
ref={containerRef}
className="relative flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border"
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
style={{
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{TIME_RANGE_OPTIONS.map((option, index) => {
const isLocked = !isLogin && option.value !== "1d";
const optionItem = (
<div
ref={setItemRef(index)}
onClick={() => {
if (!isLocked) {
if (period !== option.value) {
enableIndicatorAnimation();
}
onPeriodChange(option.value);
}
}}
@@ -562,17 +628,6 @@ export const NetworkChartClient = React.memo(function NetworkChart({
isLocked && "cursor-not-allowed opacity-40 grayscale",
)}
>
{period === option.value && (
<m.div
layoutId="network-period-selector-active"
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-20">{option.label}</span>
</div>
);
@@ -631,7 +686,10 @@ export const NetworkChartClient = React.memo(function NetworkChart({
)}
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
className={cn(
"aspect-auto h-62.5 w-full transition-opacity",
showPeriodLoading && "opacity-60",
)}
>
<ComposedChart
accessibilityLayer
@@ -746,6 +804,15 @@ export const NetworkChartClient = React.memo(function NetworkChart({
{chartElements}
</ComposedChart>
</ChartContainer>
{showPeriodLoading && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-md backdrop-blur-[1px]">
<div className="flex size-9 items-center justify-center">
<div className="absolute inset-0 flex items-center justify-center">
<div className="size-4 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/70 animate-spin" />
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
+30
View File
@@ -0,0 +1,30 @@
import type { ComponentPropsWithoutRef } from "react";
type NumericTextTransition = {
duration?: number;
easing?: string;
};
type NumericTextProps = Omit<ComponentPropsWithoutRef<"span">, "children"> & {
animated?: boolean;
respectMotionPreference?: boolean;
transition?: NumericTextTransition;
trend?: -1 | 0 | 1;
value: number | string;
};
export default function NumericText({
animated,
respectMotionPreference,
transition,
trend,
value,
...spanProps
}: NumericTextProps) {
void animated;
void respectMotionPreference;
void transition;
void trend;
return <span {...spanProps}>{value}</span>;
}
+1 -1
View File
@@ -5,7 +5,7 @@ export default function PlanInfo({
}: {
parsedData: PublicNoteData;
}) {
if (!parsedData || !parsedData.planDataMod) {
if (!parsedData?.planDataMod) {
return null;
}
+7 -3
View File
@@ -1,3 +1,4 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import ServerFlag from "@/components/ServerFlag";
@@ -8,6 +9,7 @@ import {
GetOsName,
MageMicrosoftWindows,
} from "@/lib/logo-class";
import { saveMainPageScrollPosition } from "@/lib/navigation";
import { cn, formatNezhaInfo, parsePublicNote } from "@/lib/utils";
import type { NezhaServer } from "@/types/nezha-api";
import BillingInfo from "./billingInfo";
@@ -15,7 +17,7 @@ import PlanInfo from "./PlanInfo";
import { Badge } from "./ui/badge";
import { Card } from "./ui/card";
export default function ServerCard({
function ServerCard({
now,
serverInfo,
}: {
@@ -40,7 +42,7 @@ export default function ServerCard({
} = formatNezhaInfo(now, serverInfo);
const cardClick = () => {
sessionStorage.setItem("fromMainPage", "true");
saveMainPageScrollPosition();
navigate(`/server/${serverInfo.id}`);
};
@@ -62,7 +64,7 @@ export default function ServerCard({
return online ? (
<Card
className={cn(
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 cursor-pointer hover:bg-accent/50 transition-colors",
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 transition-all hover:shadow-sm hover:ring-stone-300 md:px-5 dark:hover:ring-stone-700",
{
"flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName,
@@ -278,3 +280,5 @@ export default function ServerCard({
</Card>
);
}
export default memo(ServerCard);
+7 -3
View File
@@ -1,3 +1,4 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import ServerFlag from "@/components/ServerFlag";
@@ -8,6 +9,7 @@ import {
GetOsName,
MageMicrosoftWindows,
} from "@/lib/logo-class";
import { saveMainPageScrollPosition } from "@/lib/navigation";
import { cn, formatNezhaInfo, parsePublicNote } from "@/lib/utils";
import type { NezhaServer } from "@/types/nezha-api";
import BillingInfo from "./billingInfo";
@@ -15,7 +17,7 @@ import PlanInfo from "./PlanInfo";
import { Card } from "./ui/card";
import { Separator } from "./ui/separator";
export default function ServerCardInline({
function ServerCardInline({
now,
serverInfo,
}: {
@@ -41,7 +43,7 @@ export default function ServerCardInline({
} = formatNezhaInfo(now, serverInfo);
const cardClick = () => {
sessionStorage.setItem("fromMainPage", "true");
saveMainPageScrollPosition();
navigate(`/server/${serverInfo.id}`);
};
@@ -58,7 +60,7 @@ export default function ServerCardInline({
<section>
<Card
className={cn(
"flex items-center lg:flex-row justify-start gap-3 p-3 md:px-5 cursor-pointer hover:bg-accent/50 transition-colors min-w-[900px] w-full",
"flex w-full min-w-[900px] cursor-pointer items-center justify-start gap-3 p-3 transition-all hover:shadow-sm hover:ring-stone-300 md:px-5 lg:flex-row dark:hover:ring-stone-700",
{
"bg-card/70": customBackgroundImage,
},
@@ -238,3 +240,5 @@ export default function ServerCardInline({
</Card>
);
}
export default memo(ServerCardInline);
+51 -38
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { m } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -24,6 +23,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { formatBytes } from "@/lib/format";
import {
@@ -106,17 +106,42 @@ function PeriodSelector({
}) {
const { t } = useTranslation();
const periods: { value: ChartPeriod; label: string }[] = [
const periods = useMemo<{ value: ChartPeriod; label: string }[]>(
() => [
{ value: "realtime", label: t("serverDetailChart.realtime") },
{ value: "1d", label: t("serverDetailChart.period1d") },
{ value: "7d", label: t("serverDetailChart.period7d") },
{ value: "30d", label: t("serverDetailChart.period30d") },
];
],
[t],
);
const periodValues = useMemo(
() => periods.map((period) => period.value),
[periods],
);
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(periodValues, selectedPeriod);
return (
<TooltipProvider delayDuration={120}>
<div className="flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border">
{periods.map((period) => {
<div
ref={containerRef}
className="relative flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border"
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
style={{
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{periods.map((period, index) => {
const isHistoryPeriod = period.value !== "realtime";
const isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod;
// Only realtime and 1d are available for non-logged-in users
@@ -129,8 +154,12 @@ function PeriodSelector({
const periodItem = (
<div
ref={setItemRef(index)}
onClick={() => {
if (!isLocked) {
if (selectedPeriod !== period.value) {
enableIndicatorAnimation();
}
onPeriodChange(period.value);
}
}}
@@ -142,13 +171,6 @@ function PeriodSelector({
isLocked && "cursor-not-allowed opacity-40 grayscale",
)}
>
{selectedPeriod === period.value && (
<m.div
layoutId="period-selector-active"
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
transition={{ type: "spring", stiffness: 250, damping: 30 }}
/>
)}
<div className="relative z-20 flex items-center gap-1.5">
{period.value === "realtime" && (
<span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span>
@@ -189,7 +211,7 @@ export default function ServerDetailChart({
}: {
server_id: string;
}) {
const { lastMessage, connected, messageHistory } = useWebSocketContext();
const { lastData, connected, messageHistory } = useWebSocketContext();
const [selectedPeriod, setSelectedPeriod] = useState<ChartPeriod>("realtime");
// Check if user is logged in
@@ -234,13 +256,11 @@ export default function ServerDetailChart({
}
}, [isLogin, isTsdbEnabled, selectedPeriod]);
if (!connected && !lastMessage) {
if (!connected && !lastData) {
return <ServerDetailChartLoading />;
}
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const nezhaWsData = lastData;
if (!nezhaWsData) {
return <ServerDetailChartLoading />;
@@ -414,7 +434,7 @@ function GpuChart({
index: number;
gpuStat: number;
gpuName?: string;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const [gpuChartData, setGpuChartData] = useState<gpuChartData[]>([]);
@@ -445,8 +465,7 @@ function GpuChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === id);
if (!server) return null;
const { gpu } = formatNezhaInfo(wsData.now, server);
@@ -608,7 +627,7 @@ function CpuChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const [cpuChartData, setCpuChartData] = useState<cpuChartData[]>([]);
@@ -641,8 +660,7 @@ function CpuChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { cpu } = formatNezhaInfo(wsData.now, server);
@@ -802,7 +820,7 @@ function ProcessChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const { t } = useTranslation();
@@ -843,8 +861,7 @@ function ProcessChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { process } = formatNezhaInfo(wsData.now, server);
@@ -998,7 +1015,7 @@ function MemChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const { t } = useTranslation();
@@ -1099,8 +1116,7 @@ function MemChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { mem, swap } = formatNezhaInfo(wsData.now, server);
@@ -1317,7 +1333,7 @@ function DiskChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const { t } = useTranslation();
@@ -1361,8 +1377,7 @@ function DiskChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { disk } = formatNezhaInfo(wsData.now, server);
@@ -1531,7 +1546,7 @@ function NetworkChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const { t } = useTranslation();
@@ -1623,8 +1638,7 @@ function NetworkChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { up, down } = formatNezhaInfo(wsData.now, server);
@@ -1839,7 +1853,7 @@ function ConnectChart({
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
const [connectChartData, setConnectChartData] = useState(
@@ -1929,8 +1943,7 @@ function ConnectChart({
messageHistory.length > 0
) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
const server = wsData.servers.find((s) => s.id === data.id);
if (!server) return null;
const { tcp, udp } = formatNezhaInfo(wsData.now, server);
+59 -40
View File
@@ -11,8 +11,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { formatBytes } from "@/lib/format";
import { cn, formatNezhaInfo } from "@/lib/utils";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
import NumericText from "./NumericText";
import {
Accordion,
AccordionContent,
@@ -41,11 +40,17 @@ export default function ServerDetailOverview({
if (previousPath) {
setHasHistory(true);
}
return () => {
if (previousPath) {
sessionStorage.removeItem("fromMainPage");
}
};
}, []);
const { lastMessage, connected } = useWebSocketContext();
const { lastData, connected } = useWebSocketContext();
if (!connected && !lastMessage) {
if (!connected && !lastData) {
return <ServerDetailLoading />;
}
@@ -57,9 +62,7 @@ export default function ServerDetailOverview({
}
};
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const nezhaWsData = lastData;
if (!nezhaWsData) {
return <ServerDetailLoading />;
@@ -114,7 +117,7 @@ export default function ServerDetailOverview({
{name}
</div>
<section className="flex flex-wrap gap-2 mt-3">
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
@@ -135,24 +138,26 @@ export default function ServerDetailOverview({
</CardContent>
</Card>
{online && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
{t("serverDetail.uptime")}
</p>
<div className="text-xs">
{" "}
{uptime / 86400 >= 1
<NumericText
value={
uptime / 86400 >= 1
? `${Math.floor(uptime / 86400)} ${t("serverDetail.days")} ${Math.floor((uptime % 86400) / 3600)} ${t("serverDetail.hours")}`
: `${Math.floor(uptime / 3600)} ${t("serverDetail.hours")}`}
</div>
: `${Math.floor(uptime / 3600)} ${t("serverDetail.hours")}`
}
className="text-xs"
/>
</section>
</CardContent>
</Card>
)}
{version && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
@@ -164,7 +169,7 @@ export default function ServerDetailOverview({
</Card>
)}
{arch && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
@@ -177,7 +182,7 @@ export default function ServerDetailOverview({
)}
{mem_total ? (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
@@ -190,7 +195,7 @@ export default function ServerDetailOverview({
) : null}
{disk_total ? (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
@@ -206,7 +211,7 @@ export default function ServerDetailOverview({
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
@@ -236,7 +241,7 @@ export default function ServerDetailOverview({
</section>
<section className="flex flex-wrap gap-2 mt-1">
{platform && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
@@ -251,7 +256,7 @@ export default function ServerDetailOverview({
</Card>
)}
{cpu_info.length > 0 && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{"CPU"}</p>
@@ -261,7 +266,7 @@ export default function ServerDetailOverview({
</Card>
)}
{gpu_info.length > 0 && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{"GPU"}</p>
@@ -272,28 +277,39 @@ export default function ServerDetailOverview({
)}
</section>
<section className="flex flex-wrap gap-2 mt-1">
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{"Load"}</p>
<div className="text-xs">
{load_1} / {load_5} / {load_15}
<div className="grid grid-cols-3 gap-2 text-xs tabular-nums">
{[
{ label: "1m", value: load_1 },
{ label: "5m", value: load_5 },
{ label: "15m", value: load_15 },
].map(({ label, value }) => (
<div key={label} className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground">
{label}
</span>
<NumericText value={`${value}`} className="text-xs" />
</div>
))}
</div>
</section>
</CardContent>
</Card>
{net_out_transfer ? (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
{t("serverDetail.upload")}
</p>
{net_out_transfer ? (
<div className="text-xs">
{" "}
{formatBytes(net_out_transfer)}{" "}
</div>
<NumericText
value={formatBytes(net_out_transfer)}
className="text-xs"
/>
) : (
<div className="text-xs"> {t("serverDetail.unknown")}</div>
)}
@@ -302,17 +318,17 @@ export default function ServerDetailOverview({
</Card>
) : null}
{net_in_transfer ? (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
{t("serverDetail.download")}
</p>
{net_in_transfer ? (
<div className="text-xs">
{" "}
{formatBytes(net_in_transfer)}{" "}
</div>
<NumericText
value={formatBytes(net_in_transfer)}
className="text-xs"
/>
) : (
<div className="text-xs"> {t("serverDetail.unknown")}</div>
)}
@@ -347,7 +363,7 @@ export default function ServerDetailOverview({
</section>
<section className="flex flex-wrap gap-2 mt-1">
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
@@ -359,15 +375,18 @@ export default function ServerDetailOverview({
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<Card className="rounded-[10px] bg-transparent border-none shadow-none ring-0">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">
{t("serverDetail.lastActive")}
</p>
<div className="text-xs">
{last_active_time_string ? last_active_time_string : "N/A"}
</div>
<NumericText
value={
last_active_time_string ? last_active_time_string : "N/A"
}
className="text-xs"
/>
</section>
</CardContent>
</Card>
+3 -6
View File
@@ -3,22 +3,19 @@
import { Progress } from "@/components/ui/progress";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { formatNezhaInfo } from "@/lib/utils";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
export default function ServerDetailSummary({
server_id,
}: {
server_id: number;
}) {
const { lastMessage, connected } = useWebSocketContext();
const { lastData, connected } = useWebSocketContext();
if (!connected && !lastMessage) {
if (!connected && !lastData) {
return null;
}
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const nezhaWsData = lastData;
if (!nezhaWsData) {
return null;
+13 -5
View File
@@ -1,7 +1,11 @@
import { hasFlag } from "country-flag-icons";
import getUnicodeFlagIcon from "country-flag-icons/unicode";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
const normalizeCountryCode = (countryCode: string) =>
countryCode.trim().replace(/_/g, "-").toUpperCase();
export default function ServerFlag({
country_code,
className,
@@ -36,16 +40,20 @@ export default function ServerFlag({
};
checkEmojiSupport();
}, [forceUseSvgFlag]);
}, []);
if (!country_code) return null;
const normalizedCountryCode = normalizeCountryCode(country_code || "");
const canRenderSvgFlag = hasFlag(normalizedCountryCode);
const canRenderEmojiFlag = /^[A-Z]{2}$/.test(normalizedCountryCode);
if (!canRenderSvgFlag) return null;
return (
<span className={cn("text-[12px] text-muted-foreground", className)}>
{forceUseSvgFlag || !supportsEmojiFlags ? (
<span className={`fi fi-${country_code}`} />
{forceUseSvgFlag || !supportsEmojiFlags || !canRenderEmojiFlag ? (
<span className={`fi fi-${normalizedCountryCode.toLowerCase()}`} />
) : (
getUnicodeFlagIcon(country_code)
getUnicodeFlagIcon(normalizedCountryCode)
)}
</span>
);
+26 -17
View File
@@ -8,6 +8,8 @@ import { useStatus } from "@/hooks/use-status";
import { formatBytes } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Globe } from "lucide-react";
import NumericText from "./NumericText";
type ServerOverviewProps = {
online: number;
@@ -60,9 +62,12 @@ export default function ServerOverview({
onClick={() => {
handleServerCardClick("all");
}}
className={cn("hover:border-blue-500 cursor-pointer transition-all", {
className={cn(
"group cursor-pointer transition-all hover:ring-blue-500 dark:hover:ring-blue-600",
{
"bg-card/70": customBackgroundImage,
})}
},
)}
>
<CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1">
@@ -73,7 +78,7 @@ export default function ServerOverview({
<span className="relative flex h-2 w-2">
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500"></span>
</span>
<div className="text-lg font-semibold">{total}</div>
<NumericText value={total} className="text-lg font-semibold" />
</div>
</section>
</CardContent>
@@ -83,12 +88,13 @@ export default function ServerOverview({
handleServerCardClick("online");
}}
className={cn(
"cursor-pointer hover:ring-green-500 ring-1 ring-transparent transition-all",
"cursor-pointer ring-1 transition-all hover:ring-green-500 dark:hover:ring-green-600",
{
"bg-card/70": customBackgroundImage,
},
{
"ring-green-500 ring-2 border-transparent": activeView === "servers" && status === "online",
"border-transparent ring-2 ring-green-500 dark:ring-green-600":
activeView === "servers" && status === "online",
},
)}
>
@@ -102,8 +108,7 @@ export default function ServerOverview({
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
<div className="text-lg font-semibold">{online}</div>
<NumericText value={online} className="text-lg font-semibold" />
</div>
</section>
</CardContent>
@@ -113,14 +118,16 @@ export default function ServerOverview({
handleServerCardClick("offline");
}}
className={cn(
"cursor-pointer hover:ring-red-500 ring-1 ring-transparent transition-all",
"cursor-pointer ring-1 transition-all hover:ring-red-500 dark:hover:ring-red-600",
{
"bg-card/70": customBackgroundImage,
},
{
"ring-red-500 ring-2 border-transparent": activeView === "servers" && status === "offline",
"border-transparent ring-2 ring-red-500 dark:ring-red-600":
activeView === "servers" && status === "offline",
},
)}
>
<CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1">
@@ -132,7 +139,7 @@ export default function ServerOverview({
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
<div className="text-lg font-semibold">{offline}</div>
<NumericText value={offline} className="text-lg font-semibold" />
</div>
</section>
</CardContent>
@@ -161,7 +168,7 @@ export default function ServerOverview({
</Card>
<Card
className={cn(
"hover:ring-purple-500 ring-1 ring-transparent transition-all",
"group ring-1 hover:ring-purple-500 dark:hover:ring-purple-600",
{
"bg-card/70": customBackgroundImage,
},
@@ -175,12 +182,14 @@ export default function ServerOverview({
</p>
</div>
<section className="flex items-start flex-row z-10 pr-0 gap-1">
<p className="sm:text-[12px] text-[10px] text-blue-800 dark:text-blue-400 text-nowrap font-medium">
{formatBytes(up)}
</p>
<p className="sm:text-[12px] text-[10px] text-purple-800 dark:text-purple-400 text-nowrap font-medium">
{formatBytes(down)}
</p>
<NumericText
value={`${formatBytes(up)}`}
className="sm:text-[12px] text-[10px] text-blue-800 dark:text-blue-400 text-nowrap font-medium"
/>
<NumericText
value={`${formatBytes(down)}`}
className="sm:text-[12px] text-[10px] text-purple-800 dark:text-purple-400 text-nowrap font-medium"
/>
</section>
<section className="flex flex-col sm:flex-row -mr-1 sm:items-center items-start gap-1">
<p className="text-[11px] flex items-center text-nowrap font-semibold">
+30 -23
View File
@@ -1,5 +1,6 @@
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { fetchService } from "@/lib/nezha-api";
import type { NezhaServer, ServiceData } from "@/types/nezha-api";
@@ -8,17 +9,7 @@ import { CycleTransferStatsCard } from "./CycleTransferStats";
import { Loader } from "./loading/Loader";
import ServiceTrackerClient from "./ServiceTrackerClient";
export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
const { t } = useTranslation();
const { data: serviceData, isLoading } = useQuery({
queryKey: ["service"],
queryFn: () => fetchService(),
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchInterval: 10000,
});
const processServiceData = (serviceData: ServiceData) => {
function processServiceData(serviceData: ServiceData) {
const days = serviceData.up.map((up, index) => {
const totalChecks = up + serviceData.down[index];
const dailyUptime = totalChecks > 0 ? (up / totalChecks) * 100 : 0;
@@ -34,16 +25,36 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
const totalChecks =
serviceData.up.reduce((a, b) => a + b, 0) +
serviceData.down.reduce((a, b) => a + b, 0);
const uptime = (totalUp / totalChecks) * 100;
const uptime = totalChecks > 0 ? (totalUp / totalChecks) * 100 : 0;
const avgDelay =
serviceData.delay.length > 0
? serviceData.delay.reduce((a, b) => a + b, 0) /
serviceData.delay.length
? serviceData.delay.reduce((a, b) => a + b, 0) / serviceData.delay.length
: 0;
return { days, uptime, avgDelay };
};
}
export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
const { t } = useTranslation();
const { data: serviceData, isLoading } = useQuery({
queryKey: ["service"],
queryFn: () => fetchService(),
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchInterval: 10000,
retry: false,
});
const serviceSummaries = useMemo(() => {
return Object.entries(serviceData?.data?.services ?? {}).map(
([name, data]) => ({
...processServiceData(data),
name,
title: data.service_name,
}),
);
}, [serviceData?.data?.services]);
if (isLoading) {
return (
@@ -76,21 +87,17 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
/>
</div>
)}
{serviceData.data.services &&
Object.keys(serviceData.data.services).length > 0 && (
{serviceSummaries.length > 0 && (
<section className="grid grid-cols-1 md:grid-cols-2 mt-4 gap-2 md:gap-4">
{Object.entries(serviceData.data.services).map(([name, data]) => {
const { days, uptime, avgDelay } = processServiceData(data);
return (
{serviceSummaries.map(({ avgDelay, days, name, title, uptime }) => (
<ServiceTrackerClient
key={name}
days={days}
title={data.service_name}
title={title}
uptime={uptime}
avgDelay={avgDelay}
/>
);
})}
))}
</section>
)}
</div>
+3 -3
View File
@@ -95,14 +95,14 @@ export const ServiceTrackerClient: React.FC<ServiceTrackerProps> = ({
</div>
</div>
<div className="flex gap-[3px] bg-muted/30 p-1 rounded-lg">
<div className="flex gap-[3px] p-1 rounded-lg">
{days.map((day, index) => (
<TooltipProvider delayDuration={50} key={index}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"relative flex-1 h-7 rounded-[4px] transition-all duration-200 cursor-help",
"relative flex-1 h-7 rounded-[8px] transition-all duration-200 cursor-help",
"before:absolute before:inset-0 before:rounded-[4px] before:opacity-0 hover:before:opacity-100 before:bg-white/10 before:transition-opacity",
"after:absolute after:inset-0 after:rounded-[4px] after:shadow-[inset_0_1px_--theme(--color-white/10%)]",
day.completed
@@ -111,7 +111,7 @@ export const ServiceTrackerClient: React.FC<ServiceTrackerProps> = ({
)}
/>
</TooltipTrigger>
<TooltipContent className="p-0 overflow-hidden">
<TooltipContent className="p-0 overflow-hidden rounded-[10px]">
<div className="px-3 py-2 bg-popover">
<p className="font-medium text-sm mb-2">
{day.date?.toLocaleDateString()}
+28 -15
View File
@@ -1,5 +1,5 @@
import { m } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { cn } from "@/lib/utils";
export default function TabSwitch({
@@ -12,6 +12,8 @@ export default function TabSwitch({
setCurrentTab: (tab: string) => void;
}) {
const { t } = useTranslation();
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(tabs, currentTab);
const customBackgroundImage =
(window.CustomBackgroundImage as string) !== ""
? window.CustomBackgroundImage
@@ -19,34 +21,45 @@ export default function TabSwitch({
return (
<div className="z-50 flex flex-col items-start rounded-[50px] server-info-tab">
<div
ref={containerRef}
className={cn(
"flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
"relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
{
"bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage,
},
)}
>
{tabs.map((tab: string) => (
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 46,
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{tabs.map((tab: string, index: number) => (
<div
key={tab}
onClick={() => setCurrentTab(tab)}
ref={setItemRef(index)}
onClick={() => {
if (currentTab !== tab) {
enableIndicatorAnimation();
}
setCurrentTab(tab);
}}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-semibold transition-all duration-500",
"relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50",
currentTab === tab
? "text-black dark:text-white"
: "text-stone-400 dark:text-stone-500",
)}
>
{currentTab === tab && (
<m.div
layoutId="tab-switch-active"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{t(`tabSwitch.${tab}`)}</p>
</div>
+6 -1
View File
@@ -8,7 +8,12 @@ export function ThemeColorManager() {
useEffect(() => {
const updateThemeColor = () => {
const currentTheme = theme;
const currentTheme =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
+31 -14
View File
@@ -53,31 +53,48 @@ export function ThemeProvider({
useEffect(() => {
const root = window.document.documentElement;
root.classList.add("disable-transitions");
root.classList.remove("light", "dark");
const updateThemeColor = (nextTheme: "light" | "dark") => {
let effectiveTheme = theme;
if (theme === "system") {
effectiveTheme = isSystemDark ? "dark" : "light";
} else if (theme === "scheduled") {
// Time-based theme: 18:00 - 06:00 is dark
const isNight = hour >= 18 || hour < 6;
effectiveTheme = isNight ? "dark" : "light";
}
root.classList.add(effectiveTheme);
const themeColor =
effectiveTheme === "dark" ? "hsl(30 15% 8%)" : "hsl(0 0% 98%)";
nextTheme === "dark" ? "hsl(30 15% 8%)" : "hsl(0 0% 98%)";
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", themeColor);
};
const applyTheme = (nextTheme: "light" | "dark") => {
root.classList.remove("light", "dark");
root.classList.add(nextTheme);
root.style.colorScheme = nextTheme;
root.style.backgroundColor =
nextTheme === "dark" ? "hsl(30 15% 8%)" : "hsl(0 0% 98%)";
updateThemeColor(nextTheme);
};
root.classList.add("disable-transitions");
let effectiveTheme: "light" | "dark" = "light";
if (theme === "system") {
effectiveTheme = isSystemDark ? "dark" : "light";
} else if (theme === "scheduled") {
const isNight = hour >= 18 || hour < 6;
effectiveTheme = isNight ? "dark" : "light";
} else {
effectiveTheme = theme;
}
applyTheme(effectiveTheme);
const timeoutId = window.setTimeout(() => {
root.classList.remove("disable-transitions");
}, 0);
return () => window.clearTimeout(timeoutId);
return () => {
window.clearTimeout(timeoutId);
};
}, [theme, hour, isSystemDark]);
const value = {
theme,
setTheme: (theme: Theme) => {
+1 -1
View File
@@ -13,7 +13,7 @@ export default function BillingInfo({
parsedData: PublicNoteData;
}) {
const { t } = useTranslation();
if (!parsedData || !parsedData.billingDataMod) {
if (!parsedData?.billingDataMod) {
return null;
}
@@ -1 +0,0 @@
export { domMax as default } from "framer-motion";
-12
View File
@@ -1,12 +0,0 @@
import { LazyMotion } from "framer-motion";
const loadFeatures = () =>
import("./framer-lazy-feature").then((res) => res.default);
export const MotionProvider = ({ children }: { children: React.ReactNode }) => {
return (
<LazyMotion features={loadFeatures} strict key="framer">
{children}
</LazyMotion>
);
};
+1 -1
View File
@@ -52,4 +52,4 @@ const AccordionContent = React.forwardRef<
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+5 -5
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
"rounded-lg bg-card text-card-foreground shadow-md shadow-stone-200/50 ring ring-stone-200 dark:shadow-none dark:ring-stone-800",
className,
)}
{...props}
@@ -77,9 +77,9 @@ CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+34 -2
View File
@@ -23,6 +23,33 @@ type ChartContextProps = {
const ChartContext = React.createContext<ChartContextProps | null>(null);
const RESPONSIVE_CHART_TYPES = new Set<React.ElementType>([
RechartsPrimitive.AreaChart,
RechartsPrimitive.BarChart,
RechartsPrimitive.ComposedChart,
RechartsPrimitive.LineChart,
RechartsPrimitive.PieChart,
RechartsPrimitive.RadarChart,
RechartsPrimitive.RadialBarChart,
RechartsPrimitive.ScatterChart,
RechartsPrimitive.Treemap,
RechartsPrimitive.FunnelChart,
RechartsPrimitive.Sankey,
]);
function shouldUseResponsiveContainer(children: React.ReactNode) {
const elements = React.Children.toArray(children);
return (
elements.length > 0 &&
elements.every(
(child) =>
React.isValidElement(child) &&
RESPONSIVE_CHART_TYPES.has(child.type as React.ElementType),
)
);
}
function useChart() {
const context = React.useContext(ChartContext);
@@ -44,6 +71,7 @@ const ChartContainer = React.forwardRef<
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
const useResponsiveContainer = shouldUseResponsiveContainer(children);
return (
<ChartContext.Provider value={{ config }}>
@@ -57,9 +85,13 @@ const ChartContainer = React.forwardRef<
{...props}
>
<ChartStyle id={chartId} config={config} />
{useResponsiveContainer ? (
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
) : (
children
)}
</div>
</ChartContext.Provider>
);
@@ -389,9 +421,9 @@ function getPayloadConfigFromPayload(
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
ChartTooltip,
ChartTooltipContent,
};
+4 -4
View File
@@ -26,7 +26,7 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogTitle />
<DialogContent className="overflow-hidden p-0 shadow-lg">
<DialogContent className="overflow-hidden p-0 shadow-lg duration-0 data-[state=open]:animate-none! data-[state=closed]:animate-none!">
<Command className="**:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 **:[[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4">
{children}
</Command>
@@ -149,11 +149,11 @@ CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandShortcut,
CommandList,
CommandSeparator,
CommandShortcut,
};
+6 -6
View File
@@ -108,13 +108,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+9 -9
View File
@@ -44,7 +44,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg will-change-[transform,opacity] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
@@ -62,7 +62,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-2xl will-change-[transform,opacity] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
@@ -180,18 +180,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
};
+1 -1
View File
@@ -25,4 +25,4 @@ const PopoverContent = React.forwardRef<
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
export { Popover, PopoverContent, PopoverTrigger };
+6 -6
View File
@@ -145,13 +145,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectGroup,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+3 -3
View File
@@ -106,11 +106,11 @@ TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableCell,
TableCaption,
};
+1 -1
View File
@@ -24,4 +24,4 @@ const TooltipContent = React.forwardRef<
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+58 -4
View File
@@ -1,10 +1,64 @@
import { type ReactNode, useState } from "react";
import { type ReactNode, useEffect, useState } from "react";
import { SortContext, type SortOrder, type SortType } from "./sort-context";
import {
SORT_ORDERS,
SORT_TYPES,
SortContext,
type SortOrder,
type SortType,
} from "./sort-context";
const getForcedSortType = (): SortType | undefined => {
const forcedSortType = window.ForceSortType;
if (forcedSortType && SORT_TYPES.includes(forcedSortType as SortType)) {
return forcedSortType as SortType;
}
return undefined;
};
const getForcedSortOrder = (): SortOrder | undefined => {
const forcedSortOrder = window.ForceSortOrder;
if (forcedSortOrder && SORT_ORDERS.includes(forcedSortOrder as SortOrder)) {
return forcedSortOrder as SortOrder;
}
return undefined;
};
export function SortProvider({ children }: { children: ReactNode }) {
const [sortType, setSortType] = useState<SortType>("default");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [sortType, setSortType] = useState<SortType>(
() => getForcedSortType() || "default",
);
const [sortOrder, setSortOrder] = useState<SortOrder>(
() => getForcedSortOrder() || "desc",
);
useEffect(() => {
const applyForcedSort = () => {
const forcedSortType = getForcedSortType();
const forcedSortOrder = getForcedSortOrder();
if (forcedSortType) {
setSortType(forcedSortType);
}
if (forcedSortOrder) {
setSortOrder(forcedSortOrder);
}
};
applyForcedSort();
let retryCount = 0;
const intervalId = window.setInterval(() => {
retryCount += 1;
if (window.ForceSortType || window.ForceSortOrder || retryCount >= 50) {
applyForcedSort();
window.clearInterval(intervalId);
}
}, 100);
return () => {
window.clearInterval(intervalId);
};
}, []);
return (
<SortContext.Provider
+4 -3
View File
@@ -1,16 +1,17 @@
import { createContext } from "react";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
export interface WebSocketContextType {
lastMessage: { data: string } | null;
lastData: NezhaWebsocketResponse | null;
connected: boolean;
messageHistory: { data: string }[];
messageHistory: NezhaWebsocketResponse[];
reconnect: () => void;
needReconnect: boolean;
setNeedReconnect: (needReconnect: boolean) => void;
}
export const WebSocketContext = createContext<WebSocketContextType>({
lastMessage: null,
lastData: null,
connected: false,
messageHistory: [],
reconnect: () => {},
+41 -7
View File
@@ -1,6 +1,6 @@
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
import {
WebSocketContext,
type WebSocketContextType,
@@ -11,12 +11,38 @@ interface WebSocketProviderProps {
children: React.ReactNode;
}
type RawNezhaWebsocketResponse = Omit<
Partial<NezhaWebsocketResponse>,
"servers"
> & {
servers?: unknown;
};
function normalizeWebSocketResponse(data: unknown): NezhaWebsocketResponse {
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new TypeError("WebSocket message must be an object");
}
const response = data as RawNezhaWebsocketResponse;
if (typeof response.now !== "number" || !Number.isFinite(response.now)) {
throw new TypeError("WebSocket message must include a finite now value");
}
return {
...response,
now: response.now,
servers: Array.isArray(response.servers) ? response.servers : [],
};
}
export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
url,
children,
}) => {
const [lastMessage, setLastMessage] = useState<{ data: string } | null>(null);
const [messageHistory, setMessageHistory] = useState<{ data: string }[]>([]); // 新增历史消息状态
const [lastData, setLastData] = useState<NezhaWebsocketResponse | null>(null);
const [messageHistory, setMessageHistory] = useState<
NezhaWebsocketResponse[]
>([]);
const [connected, setConnected] = useState(false);
const [needReconnect, setNeedReconnect] = useState(false);
const ws = useRef<WebSocket | null>(null);
@@ -85,13 +111,21 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
};
ws.current.onmessage = (event) => {
const newMessage = { data: event.data };
setLastMessage(newMessage);
try {
if (typeof event.data !== "string") {
throw new Error("WebSocket message data must be a string");
}
const newData = normalizeWebSocketResponse(JSON.parse(event.data));
setLastData(newData);
// 更新历史消息,保持最新的30条记录
setMessageHistory((prev) => {
const updated = [newMessage, ...prev];
const updated = [newData, ...prev];
return updated.slice(0, 30);
});
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.current.onerror = (error) => {
@@ -130,7 +164,7 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
}, [cleanup, connect]);
const contextValue: WebSocketContextType = {
lastMessage,
lastData,
connected,
messageHistory,
reconnect,
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
type ActiveIndicator = {
x: number;
y: number;
width: number;
height: number;
shouldAnimate: boolean;
};
export function useActiveIndicator<T>(items: T[], activeItem: T) {
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const hasMeasuredRef = useRef(false);
const shouldAnimateNextRef = useRef(false);
const [indicator, setIndicator] = useState<ActiveIndicator | null>(null);
const enableIndicatorAnimation = useCallback(() => {
shouldAnimateNextRef.current = true;
}, []);
const setItemRef = useCallback(
(index: number) => (node: HTMLDivElement | null) => {
itemRefs.current[index] = node;
},
[],
);
const updateIndicator = useCallback(() => {
const activeIndex = items.indexOf(activeItem);
const activeElement = itemRefs.current[activeIndex];
if (!activeElement || !containerRef.current) {
hasMeasuredRef.current = false;
shouldAnimateNextRef.current = false;
setIndicator((currentIndicator) =>
currentIndicator === null ? currentIndicator : null,
);
return;
}
const nextIndicator = {
x: activeElement.offsetLeft,
y: activeElement.offsetTop,
width: activeElement.offsetWidth,
height: activeElement.offsetHeight,
shouldAnimate: hasMeasuredRef.current && shouldAnimateNextRef.current,
};
hasMeasuredRef.current = true;
shouldAnimateNextRef.current = false;
setIndicator((currentIndicator) => {
if (
currentIndicator &&
currentIndicator.x === nextIndicator.x &&
currentIndicator.y === nextIndicator.y &&
currentIndicator.width === nextIndicator.width &&
currentIndicator.height === nextIndicator.height
) {
return currentIndicator;
}
return nextIndicator;
});
}, [activeItem, items]);
useLayoutEffect(() => {
updateIndicator();
const container = containerRef.current;
if (!container || typeof ResizeObserver === "undefined") {
return;
}
const resizeObserver = new ResizeObserver(updateIndicator);
resizeObserver.observe(container);
for (const item of itemRefs.current) {
if (item) {
resizeObserver.observe(item);
}
}
return () => {
resizeObserver.disconnect();
};
}, [updateIndicator]);
return {
containerRef,
enableIndicatorAnimation,
indicator,
itemRefs,
setItemRef,
};
}
+2
View File
@@ -8,6 +8,8 @@ declare global {
ForceCardInline: boolean;
ForceShowMap: boolean;
ForcePeakCutEnabled: boolean;
ForceSortType?: string;
ForceSortOrder?: string;
}
}
+2 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import type { NezhaWebsocketResponse } from "@/types/nezha-api";
export function useChartHistory<T>(
messageHistory: { data: string }[],
messageHistory: NezhaWebsocketResponse[],
serverId: number,
formatFn: (wsData: NezhaWebsocketResponse, serverId: number) => T | null,
) {
@@ -11,8 +11,7 @@ export function useChartHistory<T>(
useEffect(() => {
if (messageHistory.length > 0 && data.length === 0) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
.map((wsData) => {
return formatFn(wsData, serverId);
})
.filter((item): item is T => item !== null)
+53 -4
View File
@@ -101,10 +101,6 @@
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -193,6 +189,12 @@
}
html {
@apply scroll-smooth;
background-color: hsl(0 0% 98%);
color-scheme: light;
}
html.dark {
background-color: hsl(30 15% 8%);
color-scheme: dark;
}
body {
@apply bg-background text-foreground;
@@ -337,6 +339,53 @@
display: none; /* Chrome, Safari 和 Opera */
}
.tooltip-animate {
opacity: 0;
filter: blur(5px);
animation: tooltip-fade-in 0.1s ease-in-out forwards;
}
.refresh-toast-animate {
animation: refresh-toast-in 0.5s var(--timing) forwards;
}
.active-indicator-fade-in {
animation: active-indicator-fade-in 0.18s var(--timing) forwards;
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
filter: blur(5px);
}
to {
opacity: 1;
filter: blur(0px);
}
}
@keyframes active-indicator-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes refresh-toast-in {
from {
opacity: 0;
filter: blur(10px);
transform: translateX(-50%) scale(0.8);
}
to {
opacity: 1;
filter: blur(0px);
transform: translateX(-50%) scale(1);
}
}
/* Thanks to next.js. */
[data-issues-count-animation] {
display: flex;
+5 -14
View File
@@ -1,21 +1,12 @@
export function formatBytes(bytes: number, decimals: number = 2) {
if (!+bytes) return "0 Bytes";
if (!+bytes) return "0 KiB";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = [
"Bytes",
"KiB",
"MiB",
"GiB",
"TiB",
"PiB",
"EiB",
"ZiB",
"YiB",
];
const sizes = ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const i = Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)) - 1);
const value = (bytes / k ** (i + 1)).toFixed(dm);
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
return `${value} ${sizes[i]}`;
}
+10
View File
@@ -0,0 +1,10 @@
export function saveMainPageScrollPosition() {
const scrollPosition =
window.scrollY ||
document.documentElement.scrollTop ||
document.body.scrollTop ||
0;
sessionStorage.setItem("fromMainPage", "true");
sessionStorage.setItem("scrollPosition", String(scrollPosition));
}
+23 -1
View File
@@ -11,6 +11,22 @@ import type {
let lastestRefreshTokenAt = 0;
const getCookieValue = (name: string): string | undefined => {
const cookie = document.cookie
.split("; ")
.find((item) => item.startsWith(`${name}=`));
if (!cookie) {
return undefined;
}
return decodeURIComponent(cookie.split("=").slice(1).join("="));
};
const getCsrfToken = (): string => {
return getCookieValue("nz-csrf") || "";
};
export const fetchServerGroup = async (): Promise<ServerGroupResponse> => {
const response = await fetch("/api/v1/server-group");
const data = await response.json();
@@ -34,7 +50,13 @@ export const fetchLoginUser = async (): Promise<LoginUserResponse> => {
Date.now() - lastestRefreshTokenAt > 1000 * 60 * 60)
) {
lastestRefreshTokenAt = Date.now();
fetch("/api/v1/refresh-token");
const csrfToken = getCsrfToken();
fetch("/api/v1/refresh-token", {
method: "POST",
headers: {
"X-CSRF-Token": csrfToken,
},
});
}
return data;
+20 -2
View File
@@ -83,7 +83,9 @@
"websocketConnecting": "WebSocket verbindet",
"websocketConnected": "WebSocket verbunden",
"websocketDisconnected": "WebSocket getrennt",
"processing": "Verarbeiten..."
"processing": "Verarbeiten...",
"noServers": "Keine Server verfügbar",
"noMatchingServers": "Keine Server entsprechen den aktuellen Filtern"
},
"tabSwitch": {
"Network": "Netzwerk",
@@ -122,5 +124,21 @@
"footer": {
"themeBy": "Design von "
},
"login": "Login"
"login": "Login",
"sort": {
"label": "Sortieren",
"types": {
"default": "Standard",
"name": "Name",
"uptime": "Betriebszeit",
"system": "System",
"cpu": "CPU",
"mem": "RAM",
"disk": "Festplatte",
"up": "Upload",
"down": "Download",
"up_total": "Upload ges.",
"down_total": "Download ges."
}
}
}
+26 -2
View File
@@ -11,7 +11,9 @@
"websocketConnecting": "WebSocket connecting",
"websocketConnected": "WebSocket connected",
"websocketDisconnected": "WebSocket disconnected",
"processing": "Processing..."
"processing": "Processing...",
"noServers": "No servers available",
"noMatchingServers": "No servers match the current filters"
},
"serverOverview": {
"totalServers": "Total Servers",
@@ -106,7 +108,9 @@
},
"error": {
"pageNotFound": "Page not found",
"backToHome": "Back to home"
"backToHome": "Back to home",
"backendUnavailableTitle": "Backend API unavailable",
"backendUnavailableDescription": "The page is still available, but data cannot be loaded right now."
},
"tabSwitch": {
"Detail": "Detail",
@@ -153,5 +157,25 @@
"expiryPrefix": "Expires",
"days": "Days",
"unknownRegistrar": "Unknown Registrar"
},
"sort": {
"label": "Sort",
"types": {
"default": "Default",
"name": "Name",
"uptime": "Uptime",
"system": "System",
"cpu": "CPU",
"mem": "Mem",
"disk": "Disk",
"up": "Up",
"down": "Down",
"up_total": "Up Total",
"down_total": "Down Total"
}
},
"group": {
"all": "All"
}
}
+19 -1
View File
@@ -127,7 +127,9 @@
"websocketConnecting": "Conexión WebSocket",
"websocketDisconnected": "WebSocket desconectado",
"websocketConnected": "WebSocket conectado",
"processing": "Procesando..."
"processing": "Procesando...",
"noServers": "No hay servidores disponibles",
"noMatchingServers": "Ningún servidor coincide con los filtros actuales"
},
"NoResults": "No se encontraron resultados.",
"refreshing": "Actualizando",
@@ -137,5 +139,21 @@
"ToggleSystemMode": "Activar modo del sistema",
"footer": {
"themeBy": "Tema por. "
},
"sort": {
"label": "Ordenar",
"types": {
"default": "Predeterminado",
"name": "Nombre",
"uptime": "Tiempo activo",
"system": "Sistema",
"cpu": "CPU",
"mem": "Memoria",
"disk": "Disco",
"up": "Subida",
"down": "Bajada",
"up_total": "Subida total",
"down_total": "Bajada total"
}
}
}
+16 -1
View File
@@ -1 +1,16 @@
{}
{
"online": "En ligne",
"offline": "Hors ligne",
"login": "Connexion",
"NoResults": "Aucun résultat trouvé.",
"serverCard": {
"download": "Téléchargement",
"totalDownload": "Téléchargement"
},
"serverDetailChart": {
"download": "Téléchargement"
},
"serverDetail": {
"download": "Téléchargement"
}
}
+155 -11
View File
@@ -1,27 +1,171 @@
{
"nezha": "Monitor Nezha",
"overview": "Ringkasan",
"nezha": "Pemantauan Nezha",
"overview": "Ikhtisar",
"dashboard": "Dasbor",
"login": "Masuk",
"online": "Online",
"offline": "Offline",
"whereTheTimeIs": "Dimana waktunya",
"online": "Daring",
"offline": "Luring",
"whereTheTimeIs": "Di mana waktunya",
"refreshing": "Menyegarkan",
"info": {
"websocketConnecting": "WebSocket menghubungkan",
"processing": "Memproses...",
"websocketDisconnected": "WebSocket terputus",
"websocketConnected": "WebSocket terhubung"
"websocketConnected": "WebSocket terhubung",
"noServers": "Tidak ada server yang tersedia",
"noMatchingServers": "Tidak ada server yang cocok dengan filter saat ini"
},
"serverOverview": {
"totalServers": "Jumlah Server",
"onlineServers": "Online Server",
"offlineServers": "Offline Server",
"totalServers": "Total Server",
"onlineServers": "Server Daring",
"offlineServers": "Server Luring",
"speed": "Kecepatan",
"totalBandwidth": "Jumlah Bandwidth"
"totalBandwidth": "Total Bandwidth",
"network": "Jaringan"
},
"theme": {
"light": "Terang",
"dark": "Gelap"
"dark": "Gelap",
"system": "Sistem"
},
"map": {
"Distributions": "Server didistribusikan di",
"Regions": "Wilayah",
"Servers": "server"
},
"serverCard": {
"mem": "MEM",
"stg": "STG",
"days": "Hari",
"hours": "Jam",
"upload": "Unggah",
"download": "Unduh",
"system": "Sistem",
"uptime": "Waktu aktif",
"totalUpload": "Unggah",
"totalDownload": "Unduh"
},
"cycleTransfer": {
"used": "digunakan",
"total": "total",
"nextUpdate": "pembaruan berikutnya"
},
"serviceTracker": {
"noService": "Tidak ada data layanan",
"uptime": "Waktu aktif",
"delay": "Tunda",
"daysAgo": "hari yang lalu",
"today": "Hari ini",
"loading": "Memuat..."
},
"serverDetail": {
"status": "Status",
"online": "Daring",
"days": "Hari",
"hours": "Jam",
"offline": "Luring",
"unknown": "Tidak diketahui",
"uptime": "Waktu aktif",
"version": "Versi",
"arch": "Arsitektur",
"mem": "Memori",
"disk": "Disk",
"region": "Wilayah",
"system": "Sistem",
"upload": "Unggah",
"download": "Unduh",
"lastActive": "Waktu aktif terakhir",
"temperature": "Suhu",
"bootTime": "Waktu boot"
},
"serverDetailChart": {
"process": "Proses",
"disk": "Disk",
"mem": "Memori",
"swap": "Swap",
"upload": "Unggah",
"download": "Unduh",
"realtime": "Waktu nyata",
"period1d": "1 Hari",
"period7d": "7 Hari",
"period30d": "30 Hari",
"tsdbRequired": "Aktifkan TSDB untuk menggunakan data historis",
"loginRequired": "Silakan masuk untuk melihat"
},
"footer": {
"themeBy": "Tema oleh "
},
"language": {
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"en-US": "Inggris",
"de-DE": "Jerman",
"es-ES": "Spanyol",
"ru-RU": "Rusia",
"ta-IN": "Tamil"
},
"error": {
"pageNotFound": "Halaman tidak ditemukan",
"backToHome": "Kembali ke beranda",
"backendUnavailableTitle": "API backend tidak tersedia",
"backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini."
},
"tabSwitch": {
"Detail": "Detail",
"Network": "Jaringan"
},
"monitor": {
"noData": "Tidak ada data pemantauan server, harap tambahkan pemantauan layanan terlebih dahulu",
"avgDelay": "Latensi",
"monitorCount": "Layanan",
"packetLoss": "Kehilangan Paket",
"clearSelections": "Bersihkan",
"peakCut": "Pemotongan puncak",
"loginRequired": "Silakan masuk untuk melihat",
"period1d": "1 Hari",
"period7d": "7 Hari",
"period30d": "30 Hari"
},
"pwa": {
"offlineReady": "Aplikasi siap bekerja luring",
"newContent": "Konten baru tersedia",
"reload": "Perbarui"
},
"billingInfo": {
"remaining": "Tersisa",
"error": "galat",
"indefinite": "Tak terbatas",
"expired": "Kedaluwarsa",
"days": "hari",
"price": "Harga",
"free": "Gratis",
"usage-baseed": "Berdasarkan penggunaan"
},
"TypeCommand": "Ketik perintah atau cari...",
"NoResults": "Tidak ada hasil ditemukan.",
"Servers": "Server",
"Shortcuts": "Jalan pintas",
"ToggleLightMode": "Alihkan Mode Terang",
"ToggleDarkMode": "Alihkan Mode Gelap",
"ToggleSystemMode": "Alihkan Mode Sistem",
"Home": "Beranda",
"sort": {
"label": "Urutkan",
"types": {
"default": "Default",
"name": "Nama",
"uptime": "Waktu aktif (uptime)",
"system": "Sistem",
"cpu": "CPU",
"mem": "Mem",
"disk": "Disk",
"up": "Unggah",
"down": "Unduh",
"up_total": "Total Unggah",
"down_total": "Total Unduh"
}
},
"group": {
"all": "Semua"
}
}
+27 -1
View File
@@ -1 +1,27 @@
{}
{
"overview": "概要",
"nezha": "ナタモニタリング",
"dashboard": "ダッシュボード",
"login": "ログイン",
"online": "オンライン",
"offline": "オフライン",
"whereTheTimeIs": "現在の時刻",
"refreshing": "リフレッシュ",
"info": {
"websocketConnecting": "WebSocketを接続中",
"websocketConnected": "WebSocketを接続済み",
"websocketDisconnected": "WebSocketが切断された",
"processing": "処理中..."
},
"serverOverview": {
"totalServers": "サーバー総数",
"onlineServers": "オンラインサーバー",
"offlineServers": "オフラインサーバー",
"speed": "速度",
"network": "ネットワーク"
},
"map": {
"Distributions": "サーバーの場所",
"Regions": "地域"
}
}
+167
View File
@@ -0,0 +1,167 @@
{
"overview": "Visão geral",
"dashboard": "Painel de controle",
"whereTheTimeIs": "Onde está a hora",
"refreshing": "Atualizando",
"info": {
"websocketConnecting": "Conectando WebSocket",
"websocketConnected": "WebSocket conectado",
"websocketDisconnected": "WebSocket desconectado",
"processing": "Processando...",
"noServers": "Nenhum servidor disponível",
"noMatchingServers": "Nenhum servidor corresponde aos filtros atuais"
},
"serverOverview": {
"totalServers": "Total de Servidores",
"onlineServers": "Servidores Conectados",
"offlineServers": "Servidores Desconectados",
"totalBandwidth": "Largura de Banda Total",
"speed": "Velocidade",
"network": "Rede"
},
"map": {
"Distributions": "Servidores estão distribuídos em",
"Regions": "Regiões",
"Servers": "servidores"
},
"serverCard": {
"mem": "MEM",
"stg": "STG",
"days": "Dias",
"hours": "Horas",
"upload": "Upload",
"download": "Download",
"system": "Sistema",
"uptime": "Disponibilidade",
"totalUpload": "Envio total",
"totalDownload": "Recebimento total"
},
"nezha": "Nezha Monitoramento",
"login": "Login",
"online": "Online",
"offline": "Offline",
"cycleTransfer": {
"used": "Usado",
"total": "Total",
"nextUpdate": "Próxima atualização"
},
"serviceTracker": {
"noService": "Sem dados do serviço",
"uptime": "Disponibilidade",
"delay": "Atraso",
"daysAgo": "Dias anteriores",
"today": "Hoje",
"loading": "Carregando..."
},
"serverDetail": {
"status": "Status",
"online": "Online",
"days": "Dias",
"hours": "Horas",
"offline": "Offline",
"unknown": "Desconhecido",
"uptime": "Disponibilidade",
"version": "Versão",
"arch": "Arquitetura",
"mem": "Memória",
"disk": "Armazenamento",
"region": "Região",
"system": "Sistema",
"upload": "Envio",
"download": "Recebimento",
"lastActive": "Ultima atividade",
"temperature": "Temperatura",
"bootTime": "Tempo de inicio"
},
"serverDetailChart": {
"process": "Processo",
"disk": "Armazenamento",
"mem": "Memória",
"swap": "Swap",
"upload": "Envio",
"download": "Recebimento",
"realtime": "Em tempo real",
"period1d": "1 Dia",
"period7d": "7 Dias",
"period30d": "30 Dias",
"tsdbRequired": "Habilite o TSDB para usar dados históricos",
"loginRequired": "Por favor entre para visualizar"
},
"footer": {
"themeBy": "Tema por "
},
"language": {
"zh-CN": "Chinês simplificado",
"zh-TW": "Chinês Tradicional",
"en-US": "Inglês",
"de-DE": "Alemão",
"es-ES": "Espanhol",
"ru-RU": "Russo",
"ta-IN": "Tamil Indiano"
},
"theme": {
"light": "Claro",
"dark": "Escuro",
"system": "Sistema"
},
"error": {
"pageNotFound": "Página não encontrada",
"backToHome": "Voltar ao inicio",
"backendUnavailableTitle": "API Backend indisponível",
"backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora."
},
"tabSwitch": {
"Detail": "Detalhes",
"Network": "Rede"
},
"monitor": {
"noData": "Sem servidores para monitorar. por favor adicione um primeiro monitor de serviço",
"avgDelay": "Latência",
"monitorCount": "Serviços",
"packetLoss": "Perda de pacotes",
"clearSelections": "Limpar",
"peakCut": "Corte de pico",
"loginRequired": "Por favor entre para visualizar",
"period1d": "1 Dia",
"period7d": "7 Dias",
"period30d": "30 Dias"
},
"pwa": {
"offlineReady": "Aplicativo pronto para funcionar offline",
"newContent": "Novo conteúdo disponivel",
"reload": "Atualizar"
},
"billingInfo": {
"remaining": "Restante",
"error": "Erro",
"indefinite": "Indefinido",
"expired": "Expirado",
"days": "dias",
"price": "Preço",
"free": "Grátis",
"usage-baseed": "Baseado no uso"
},
"TypeCommand": "Digite um comando ou pesquisa...",
"NoResults": "Nenhum resultado encontrado.",
"Servers": "Servidores",
"Shortcuts": "Atalhos",
"ToggleLightMode": "Habilitar tema claro",
"ToggleDarkMode": "habilitar tema escuro",
"ToggleSystemMode": "Habilitar modo do sistema",
"Home": "Inicio",
"sort": {
"label": "Organizar",
"types": {
"default": "Padrão",
"name": "Nome",
"uptime": "Disponibilidade",
"system": "Sistema",
"cpu": "CPU",
"mem": "Mem",
"disk": "Disco"
}
},
"group": {
"all": "Todos"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"nezha": "Monitor-ul Nezha",
"theme": {
"light": "Mod luminos",
"dark": "Mod întunecat"
},
"Servers": "Servere"
}
+20 -2
View File
@@ -53,7 +53,9 @@
"websocketConnecting": "WebSocket подключение",
"websocketConnected": "WebSocket подключен",
"websocketDisconnected": "WebSocket отключен",
"processing": "Обработка..."
"processing": "Обработка...",
"noServers": "Нет доступных серверов",
"noMatchingServers": "Нет серверов, соответствующих текущим фильтрам"
},
"cycleTransfer": {
"nextUpdate": "следующее обновление",
@@ -141,5 +143,21 @@
"themeBy": "Тема от "
},
"NoResults": "Ничего не найдено.",
"nezha": "Nezha Monitoring"
"nezha": "Nezha Monitoring",
"sort": {
"label": "Сортировка",
"types": {
"default": "По умолчанию",
"name": "Имя",
"uptime": "Аптайм",
"system": "Система",
"cpu": "CPU",
"mem": "Память",
"disk": "Диск",
"up": "Загрузка",
"down": "Скачивание",
"up_total": "Загружено",
"down_total": "Скачано"
}
}
}
+19 -1
View File
@@ -23,7 +23,9 @@
"websocketConnecting": "வெப்சாக்கெட் இணைத்தல்",
"websocketConnected": "வெப்சாக்கெட் இணைக்கப்பட்டுள்ளது",
"websocketDisconnected": "வெப்சாக்கெட் துண்டிக்கப்பட்டது",
"processing": "செயலாக்கம் ..."
"processing": "செயலாக்கம் ...",
"noServers": "சேவையகங்கள் எதுவும் இல்லை",
"noMatchingServers": "தற்போதைய வடிப்பான்களுடன் பொருந்தும் சேவையகங்கள் எதுவும் இல்லை"
},
"serverOverview": {
"totalServers": "மொத்த சேவையகங்கள்",
@@ -122,5 +124,21 @@
"daysAgo": "சில நாட்களுக்கு முன்பு",
"today": "இன்று",
"loading": "ஏற்றுகிறது ..."
},
"sort": {
"label": "வரிசைப்படுத்து",
"types": {
"default": "இயல்புநிலை",
"name": "பெயர்",
"uptime": "இயக்க நேரம்",
"system": "கணினி",
"cpu": "CPU",
"mem": "நினைவகம்",
"disk": "வட்டு",
"up": "பதிவேற்றம்",
"down": "பதிவிறக்கம்",
"up_total": "மொத்த பதிவேற்றம்",
"down_total": "மொத்த பதிவிறக்கம்"
}
}
}
+26 -2
View File
@@ -11,7 +11,9 @@
"websocketConnecting": "WebSocket 连接中",
"websocketConnected": "WebSocket 连接成功",
"websocketDisconnected": "WebSocket 连接断开",
"processing": "处理中..."
"processing": "处理中...",
"noServers": "暂无服务器",
"noMatchingServers": "没有符合当前筛选条件的服务器"
},
"serverOverview": {
"totalServers": "服务器总数",
@@ -107,7 +109,9 @@
},
"error": {
"pageNotFound": "页面不存在",
"backToHome": "回到主页"
"backToHome": "回到主页",
"backendUnavailableTitle": "后端 API 无法访问",
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。"
},
"tabSwitch": {
"Detail": "详情",
@@ -154,5 +158,25 @@
"expiryPrefix": "到期",
"days": "天",
"unknownRegistrar": "未知注册商"
},
"sort": {
"label": "排序",
"types": {
"default": "默认",
"name": "名称",
"uptime": "运行时间",
"system": "系统",
"cpu": "CPU",
"mem": "内存",
"disk": "磁盘",
"up": "上传",
"down": "下载",
"up_total": "上传总量",
"down_total": "下载总量"
}
},
"group": {
"all": "全部"
}
}
+25 -2
View File
@@ -11,7 +11,9 @@
"websocketConnecting": "WebSocket 連線中",
"websocketConnected": "WebSocket 連線成功",
"websocketDisconnected": "WebSocket 連線中斷",
"processing": "處理中..."
"processing": "處理中...",
"noServers": "暫無伺服器",
"noMatchingServers": "沒有符合目前篩選條件的伺服器"
},
"serverOverview": {
"totalServers": "總伺服器",
@@ -100,7 +102,9 @@
},
"error": {
"pageNotFound": "頁面不存在",
"backToHome": "回到主頁"
"backToHome": "回到主頁",
"backendUnavailableTitle": "後端 API 無法存取",
"backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。"
},
"tabSwitch": {
"detail": "詳細資訊",
@@ -149,5 +153,24 @@
"offlineReady": "可離線使用之應用程式",
"newContent": "有新内容可用",
"reload": "更新"
},
"sort": {
"label": "排序",
"types": {
"default": "預設",
"name": "名稱",
"uptime": "運行時間",
"system": "系統",
"cpu": "CPU",
"mem": "記憶體",
"disk": "磁碟",
"up": "上傳",
"down": "下載",
"up_total": "上傳總量",
"down_total": "下載總量"
}
},
"group": {
"all": "全部"
}
}
+13 -5
View File
@@ -1,10 +1,9 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { lazy, Suspense } from "react";
import ReactDOM from "react-dom/client";
import { Toaster } from "sonner";
import App from "./App";
import { MotionProvider } from "./components/motion/motion-provider";
import { ThemeColorManager } from "./components/ThemeColorManager";
import { ThemeProvider } from "./components/ThemeProvider";
import { CommandProvider } from "./context/command-provider";
@@ -19,6 +18,13 @@ import "./index.css";
initCustomConfig();
const queryClient = new QueryClient();
const ReactQueryDevtools = import.meta.env.DEV
? lazy(() =>
import("@tanstack/react-query-devtools").then((module) => ({
default: module.ReactQueryDevtools,
})),
)
: null;
const rootElement = document.getElementById("root");
if (!rootElement) {
@@ -26,7 +32,6 @@ if (!rootElement) {
}
ReactDOM.createRoot(rootElement).render(
<MotionProvider>
<ThemeProvider storageKey="vite-ui-theme">
<ThemeColorManager />
<QueryClientProvider client={queryClient}>
@@ -47,13 +52,16 @@ ReactDOM.createRoot(rootElement).render(
position="top-center"
className={"flex items-center justify-center"}
/>
{ReactQueryDevtools ? (
<Suspense fallback={null}>
<ReactQueryDevtools />
</Suspense>
) : null}
</TooltipProvider>
</SortProvider>
</StatusProvider>
</CommandProvider>
</WebSocketProvider>
</QueryClientProvider>
</ThemeProvider>
</MotionProvider>,
</ThemeProvider>,
);
+397 -264
View File
@@ -4,10 +4,11 @@ import {
ArrowUpIcon,
ChartBarSquareIcon,
MapIcon,
ServerStackIcon,
ViewColumnsIcon,
} from "@heroicons/react/20/solid";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import GlobalMap from "@/components/GlobalMap";
import GroupSwitch from "@/components/GroupSwitch";
@@ -16,44 +17,116 @@ import ServerCard from "@/components/ServerCard";
import ServerCardInline from "@/components/ServerCardInline";
import ServerOverview from "@/components/ServerOverview";
import { ServiceTracker } from "@/components/ServiceTracker";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SORT_ORDERS, SORT_TYPES } from "@/context/sort-context";
import { SORT_TYPES } from "@/context/sort-context";
import { useSort } from "@/hooks/use-sort";
import { useStatus } from "@/hooks/use-status";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { fetchServerGroup } from "@/lib/nezha-api";
import { cn, formatNezhaInfo } from "@/lib/utils";
import type { NezhaWebsocketResponse, ServerGroup } from "@/types/nezha-api";
import { fetchServerGroup, fetchService } from "@/lib/nezha-api";
import { cn } from "@/lib/utils";
import type { NezhaServer, ServerGroup } from "@/types/nezha-api";
import { DomainStatus } from "@/components/DomainStatus";
import { getDomains } from "@/api/domain";
export default function Servers() {
type PreparedServer = {
online: boolean;
server: NezhaServer;
};
const EMPTY_SERVER_LIST: NezhaServer[] = [];
const isServerOnline = (now: number, server: NezhaServer) => {
const lastActiveTime = server.last_active.startsWith("000")
? 0
: Date.parse(server.last_active);
return now - lastActiveTime <= 30000;
};
const getUsagePercent = (used = 0, total = 0) => {
if (!total) return 0;
return (used / total) * 100;
};
const getErrorMessage = (error: unknown) => {
if (!error) return "";
if (error instanceof Error) return error.message;
return String(error);
};
function BackendErrorState({ error }: { error: unknown }) {
const { t } = useTranslation();
const message = getErrorMessage(error);
return (
<div className="flex min-h-96 flex-col items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-2">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">
{t("error.backendUnavailableTitle")}
</p>
<p className="text-sm text-muted-foreground">
{t("error.backendUnavailableDescription")}
</p>
{message && (
<p className="mt-1 max-w-full break-words rounded-md bg-stone-100 px-2 py-1 text-xs text-stone-500 dark:bg-stone-800 dark:text-stone-400">
{message}
</p>
)}
</div>
</div>
);
}
function ServerEmptyState({ filtered = false }: { filtered?: boolean }) {
const { t } = useTranslation();
return (
<div
className="server-empty-state mt-6 flex min-h-52 flex-col items-center justify-center gap-2 px-4 text-center"
role="status"
>
<ServerStackIcon
aria-hidden="true"
className="size-7 text-stone-300 dark:text-stone-600"
/>
<p className="text-sm font-medium text-stone-500 dark:text-stone-400">
{t(filtered ? "info.noMatchingServers" : "info.noServers")}
</p>
</div>
);
}
export default function Servers({
backendError,
}: {
backendError?: Error | null;
}) {
const { t } = useTranslation();
const { sortType, sortOrder, setSortOrder, setSortType } = useSort();
const { data: groupData } = useQuery({
const { data: groupData, error: groupError } = useQuery({
queryKey: ["server-group"],
queryFn: () => fetchServerGroup(),
retry: false,
});
const { lastMessage, connected } = useWebSocketContext();
const { data: serviceData, error: serviceError } = useQuery({
queryKey: ["service"],
queryFn: () => fetchService(),
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchInterval: 10000,
retry: false,
});
const hasServices =
!!serviceData?.data?.services &&
Object.keys(serviceData.data.services).length > 0;
const { lastData, connected } = useWebSocketContext();
const { status } = useStatus();
const [showServices, setShowServices] = useState<string>("0");
const [showMap, setShowMap] = useState<string>("0");
const [inline, setInline] = useState<string>("0");
const containerRef = useRef<HTMLDivElement>(null);
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);
const hasRestoredScroll = useRef(false);
const [currentGroup, setCurrentGroup] = useState<string>("All");
const nezhaWsData = lastData;
const [activeView, setActiveView] = useState<'servers' | 'domains'>('servers');
@@ -77,19 +150,30 @@ export default function Servers() {
: undefined;
const restoreScrollPosition = useCallback(() => {
const isFromMainPage = sessionStorage.getItem("fromMainPage") === "true";
const savedPosition = sessionStorage.getItem("scrollPosition");
if (savedPosition && containerRef.current) {
containerRef.current.scrollTop = Number(savedPosition);
const scrollTop = savedPosition ? Number(savedPosition) : Number.NaN;
if (
hasRestoredScroll.current ||
!isFromMainPage ||
!Number.isFinite(scrollTop)
) {
return;
}
hasRestoredScroll.current = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.scrollTo({ top: scrollTop, left: 0, behavior: "auto" });
});
});
}, []);
const handleTagChange = (newGroup: string) => {
setCurrentGroup(newGroup);
sessionStorage.setItem("selectedGroup", newGroup);
sessionStorage.setItem(
"scrollPosition",
String(containerRef.current?.scrollTop || 0),
);
sessionStorage.setItem("scrollPosition", String(window.scrollY || 0));
};
useEffect(() => {
@@ -101,6 +185,12 @@ export default function Servers() {
}
}, []);
useEffect(() => {
if (!hasServices) {
setShowServices("0");
}
}, [hasServices]);
useEffect(() => {
const checkInlineSettings = () => {
const isMobile = window.innerWidth < 768;
@@ -136,29 +226,211 @@ export default function Servers() {
useEffect(() => {
const savedGroup = sessionStorage.getItem("selectedGroup") || "All";
setCurrentGroup(savedGroup);
}, []);
useEffect(() => {
if (nezhaWsData) {
restoreScrollPosition();
}, [restoreScrollPosition]);
}
}, [nezhaWsData, restoreScrollPosition]);
const nezhaWsData = lastMessage
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
: null;
const groupServerIdSets = useMemo(() => {
const sets = new Map<string, Set<number>>();
const groupTabs = [
for (const item of groupData?.data ?? []) {
if (Array.isArray(item.servers)) {
sets.set(item.group.name, new Set(item.servers));
}
}
return sets;
}, [groupData?.data]);
const groupTabs = useMemo(
() => [
"All",
...(groupData?.data
?.filter((item: ServerGroup) => {
return (
Array.isArray(item.servers) &&
item.servers.some((serverId) =>
nezhaWsData?.servers?.some((server) => server.id === serverId),
)
...(groupData?.data?.map((item: ServerGroup) => item.group.name) || []),
],
[groupData?.data],
);
})
?.map((item: ServerGroup) => item.group.name) || []),
];
const {
down,
downSpeed,
filteredServers,
offlineServers,
onlineServers,
totalServers,
up,
upSpeed,
} = useMemo(() => {
if (!nezhaWsData) {
return {
down: 0,
downSpeed: 0,
filteredServers: EMPTY_SERVER_LIST,
offlineServers: 0,
onlineServers: 0,
totalServers: 0,
up: 0,
upSpeed: 0,
};
}
if (!connected && !lastMessage) {
const currentGroupServerIds = groupServerIdSets.get(currentGroup);
const groupFilteredServers: PreparedServer[] = [];
const overview = {
down: 0,
downSpeed: 0,
offlineServers: 0,
onlineServers: 0,
up: 0,
upSpeed: 0,
};
for (const server of nezhaWsData.servers) {
if (currentGroup !== "All" && !currentGroupServerIds?.has(server.id)) {
continue;
}
const online = isServerOnline(nezhaWsData.now, server);
groupFilteredServers.push({
online,
server,
});
if (!online) {
overview.offlineServers += 1;
continue;
}
overview.onlineServers += 1;
overview.up += server.state?.net_out_transfer ?? 0;
overview.down += server.state?.net_in_transfer ?? 0;
overview.upSpeed += server.state?.net_out_speed ?? 0;
overview.downSpeed += server.state?.net_in_speed ?? 0;
}
const statusFilteredServers =
status === "all"
? groupFilteredServers
: groupFilteredServers.filter((item) =>
status === "online" ? item.online : !item.online,
);
if (sortType === "default") {
const onlineServers: NezhaServer[] = [];
const offlineServers: NezhaServer[] = [];
for (const item of statusFilteredServers) {
if (item.online) {
onlineServers.push(item.server);
} else {
offlineServers.push(item.server);
}
}
return {
...overview,
filteredServers: [...onlineServers, ...offlineServers],
totalServers: groupFilteredServers.length,
};
}
const sortedServers = [...statusFilteredServers].sort((a, b) => {
if (sortType !== "name") {
if (!a.online && b.online) return 1;
if (a.online && !b.online) return -1;
if (!a.online && !b.online) {
return 0;
}
}
let comparison = 0;
switch (sortType) {
case "name":
comparison = a.server.name.localeCompare(b.server.name);
break;
case "uptime":
comparison =
(a.server.state?.uptime ?? 0) - (b.server.state?.uptime ?? 0);
break;
case "system":
comparison = (a.server.host?.platform ?? "").localeCompare(
b.server.host?.platform ?? "",
);
break;
case "cpu":
comparison = (a.server.state?.cpu ?? 0) - (b.server.state?.cpu ?? 0);
break;
case "mem":
comparison =
getUsagePercent(
a.server.state?.mem_used,
a.server.host?.mem_total,
) -
getUsagePercent(b.server.state?.mem_used, b.server.host?.mem_total);
break;
case "disk":
comparison =
getUsagePercent(
a.server.state?.disk_used,
a.server.host?.disk_total,
) -
getUsagePercent(
b.server.state?.disk_used,
b.server.host?.disk_total,
);
break;
case "up":
comparison =
(a.server.state?.net_out_speed ?? 0) -
(b.server.state?.net_out_speed ?? 0);
break;
case "down":
comparison =
(a.server.state?.net_in_speed ?? 0) -
(b.server.state?.net_in_speed ?? 0);
break;
case "up total":
comparison =
(a.server.state?.net_out_transfer ?? 0) -
(b.server.state?.net_out_transfer ?? 0);
break;
case "down total":
comparison =
(a.server.state?.net_in_transfer ?? 0) -
(b.server.state?.net_in_transfer ?? 0);
break;
default:
comparison = 0;
}
return sortOrder === "asc" ? comparison : -comparison;
});
return {
...overview,
filteredServers: sortedServers.map((item) => item.server),
totalServers: groupFilteredServers.length,
};
}, [
currentGroup,
groupServerIdSets,
nezhaWsData,
sortOrder,
sortType,
status,
]);
const currentBackendError = backendError || groupError || serviceError;
if (!nezhaWsData && currentBackendError) {
return <BackendErrorState error={currentBackendError} />;
}
if (!connected && !lastData) {
return (
<div className="flex flex-col items-center min-h-96 justify-center ">
<div className="font-semibold flex items-center gap-2 text-sm">
@@ -177,134 +449,7 @@ export default function Servers() {
);
}
let filteredServers =
nezhaWsData?.servers?.filter((server) => {
if (currentGroup === "All") return true;
const group = groupData?.data?.find(
(g: ServerGroup) =>
g.group.name === currentGroup &&
Array.isArray(g.servers) &&
g.servers.includes(server.id),
);
return !!group;
}) || [];
const totalServers = filteredServers.length || 0;
const onlineServers =
filteredServers.filter(
(server) => formatNezhaInfo(nezhaWsData.now, server).online,
)?.length || 0;
const offlineServers =
filteredServers.filter(
(server) => !formatNezhaInfo(nezhaWsData.now, server).online,
)?.length || 0;
const up =
filteredServers.reduce(
(total, server) =>
formatNezhaInfo(nezhaWsData.now, server).online
? total + (server.state?.net_out_transfer ?? 0)
: total,
0,
) || 0;
const down =
filteredServers.reduce(
(total, server) =>
formatNezhaInfo(nezhaWsData.now, server).online
? total + (server.state?.net_in_transfer ?? 0)
: total,
0,
) || 0;
const upSpeed =
filteredServers.reduce(
(total, server) =>
formatNezhaInfo(nezhaWsData.now, server).online
? total + (server.state?.net_out_speed ?? 0)
: total,
0,
) || 0;
const downSpeed =
filteredServers.reduce(
(total, server) =>
formatNezhaInfo(nezhaWsData.now, server).online
? total + (server.state?.net_in_speed ?? 0)
: total,
0,
) || 0;
filteredServers =
status === "all"
? filteredServers
: filteredServers.filter((server) =>
[status].includes(
formatNezhaInfo(nezhaWsData.now, server).online
? "online"
: "offline",
),
);
filteredServers = filteredServers.sort((a, b) => {
const serverAInfo = formatNezhaInfo(nezhaWsData.now, a);
const serverBInfo = formatNezhaInfo(nezhaWsData.now, b);
if (sortType !== "name") {
// 仅在非 "name" 排序时,先按在线状态排序
if (!serverAInfo.online && serverBInfo.online) return 1;
if (serverAInfo.online && !serverBInfo.online) return -1;
if (!serverAInfo.online && !serverBInfo.online) {
// 如果两者都离线,可以继续按照其他条件排序,或者保持原序
// 这里选择保持原序
return 0;
}
}
let comparison = 0;
switch (sortType) {
case "name":
comparison = a.name.localeCompare(b.name);
break;
case "uptime":
comparison = (a.state?.uptime ?? 0) - (b.state?.uptime ?? 0);
break;
case "system":
comparison = a.host.platform.localeCompare(b.host.platform);
break;
case "cpu":
comparison = (a.state?.cpu ?? 0) - (b.state?.cpu ?? 0);
break;
case "mem":
comparison =
(formatNezhaInfo(nezhaWsData.now, a).mem ?? 0) -
(formatNezhaInfo(nezhaWsData.now, b).mem ?? 0);
break;
case "disk":
comparison =
(formatNezhaInfo(nezhaWsData.now, a).disk ?? 0) -
(formatNezhaInfo(nezhaWsData.now, b).disk ?? 0);
break;
case "up":
comparison =
(a.state?.net_out_speed ?? 0) - (b.state?.net_out_speed ?? 0);
break;
case "down":
comparison =
(a.state?.net_in_speed ?? 0) - (b.state?.net_in_speed ?? 0);
break;
case "up total":
comparison =
(a.state?.net_out_transfer ?? 0) - (b.state?.net_out_transfer ?? 0);
break;
case "down total":
comparison =
(a.state?.net_in_transfer ?? 0) - (b.state?.net_in_transfer ?? 0);
break;
default:
comparison = 0;
}
return sortOrder === "asc" ? comparison : -comparison;
});
const hasServers = nezhaWsData.servers.length > 0;
const totalDomainsCount = domains?.length || 0;
@@ -322,9 +467,12 @@ export default function Servers() {
onViewChange={setActiveView}
activeView={activeView}
/>
{activeView !== 'domains' && (
{activeView !== "domains" && (
<>
<div className="flex mt-6 items-center justify-between gap-2 server-overview-controls">
<div
hidden={!hasServers}
className="flex mt-6 items-center justify-between gap-2 server-overview-controls"
>
<section className="flex items-center gap-2 w-full overflow-hidden">
<button
onClick={() => {
@@ -342,8 +490,9 @@ export default function Servers() {
},
)}
>
<MapIcon className={cn("size-[13px]")} />
<MapIcon className="size-[13px]" />
</button>
{hasServices && (
<button
onClick={() => {
setShowServices(showServices === "0" ? "1" : "0");
@@ -363,8 +512,9 @@ export default function Servers() {
},
)}
>
<ChartBarSquareIcon className={cn("size-[13px]")} />
<ChartBarSquareIcon className="size-[13px]" />
</button>
)}
<button
onClick={() => {
setInline(inline === "0" ? "1" : "0");
@@ -381,11 +531,7 @@ export default function Servers() {
},
)}
>
<ViewColumnsIcon
className={cn("size-[13px]", {
"text-white": inline === "1",
})}
/>
<ViewColumnsIcon className="size-[13px]" />
</button>
<GroupSwitch
tabs={groupTabs}
@@ -393,117 +539,103 @@ export default function Servers() {
setCurrentTab={handleTagChange}
/>
</section>
<Popover onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
<button
<div
className={cn(
"rounded-[50px] flex items-center gap-1 dark:text-white border dark:border-none text-black cursor-pointer dark:[text-shadow:0_1px_0_rgb(0_0_0/20%)] dark:bg-stone-800 bg-white p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] ",
"flex h-8 items-center rounded-full border border-stone-200 bg-white text-sm text-stone-600 shadow-xs transition-all dark:border-stone-800 dark:bg-stone-800 dark:text-stone-300 dark:shadow-none shrink-0",
{
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] dark:bg-stone-700 bg-stone-200":
settingsOpen,
},
{
"dark:bg-stone-800/70 bg-stone-100/70 ":
"dark:border-stone-600/80 dark:bg-stone-800/80 bg-white/75":
customBackgroundImage,
},
{
" text-blue-600 dark:text-blue-400": sortType !== "default",
},
)}
>
<p className="text-[10px] font-bold whitespace-nowrap">
{sortType === "default" ? "Sort" : sortType.toUpperCase()}
</p>
{sortOrder === "asc" && sortType !== "default" ? (
<ArrowUpIcon className="size-[13px]" />
) : sortOrder === "desc" && sortType !== "default" ? (
<ArrowDownIcon className="size-[13px]" />
) : (
<ArrowsUpDownIcon className="size-[13px]" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="p-4 w-[240px] rounded-lg">
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Sort by
</Label>
<Select value={sortType} onValueChange={setSortType}>
<SelectTrigger className="w-full text-xs h-8">
<SelectValue placeholder="Choose type" />
</SelectTrigger>
<SelectContent>
{SORT_TYPES.map((type) => (
<SelectItem key={type} value={type} className="text-xs">
{type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Sort order
</Label>
<Select
value={sortOrder}
onValueChange={setSortOrder}
<button
aria-label="Toggle sort direction"
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
disabled={sortType === "default"}
className="flex h-full cursor-pointer items-center gap-1.5 px-3 disabled:cursor-not-allowed disabled:opacity-40"
>
<SelectTrigger className="w-full text-xs h-8">
<SelectValue placeholder="Choose order" />
</SelectTrigger>
<SelectContent>
{SORT_ORDERS.map((order) => (
<SelectItem key={order} value={order} className="text-xs">
{order.charAt(0).toUpperCase() + order.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{showMap === "1" && (
<GlobalMap
now={nezhaWsData.now}
serverList={nezhaWsData?.servers || []}
/>
<div className="text-stone-900 dark:text-stone-100">
{sortOrder === "asc" && sortType !== "default" ? (
<ArrowUpIcon className="size-3.5 shrink-0" />
) : sortOrder === "desc" && sortType !== "default" ? (
<ArrowDownIcon className="size-3.5 shrink-0" />
) : (
<ArrowsUpDownIcon className="size-3.5 shrink-0" />
)}
{showServices === "1" && <ServiceTracker serverList={filteredServers} />}
{inline === "1" && (
<section
ref={containerRef}
className="flex flex-col gap-2 overflow-x-scroll scrollbar-hidden mt-6 server-inline-list"
</div>
<span className="font-medium text-stone-900 dark:text-stone-100">
{t("sort.label")}
</span>
</button>
<span className="text-stone-300 dark:text-stone-600 mb-0.5">|</span>
<span className="relative ml-2 mr-3.25 inline-flex items-center">
<span
className="pointer-events-none select-none opacity-0 text-sm font-medium whitespace-nowrap"
aria-hidden
>
{t(`sort.types.${sortType.replace(/ /g, "_")}`)}
</span>
<select
aria-label="Sort metric"
value={sortType}
onChange={(e) => {
const val = e.target.value as typeof sortType;
setSortType(val);
if (val === "default") setSortOrder("desc");
}}
className="absolute inset-0 cursor-pointer appearance-none bg-transparent text-sm font-medium outline-none"
>
{SORT_TYPES.map((type) => (
<option key={type} value={type}>
{t(`sort.types.${type.replace(/ /g, "_")}`)}
</option>
))}
</select>
</span>
</div>
</div>
{hasServers && showMap === "1" && (
<GlobalMap now={nezhaWsData.now} serverList={nezhaWsData.servers} />
)}
{hasServers && showServices === "1" && (
<ServiceTracker serverList={filteredServers} />
)}
{!hasServers ? (
<ServerEmptyState />
) : filteredServers.length > 0 ? (
inline === "1" ? (
<section className="flex flex-col gap-2 overflow-x-scroll p-px scrollbar-hidden mt-6 server-inline-list">
{filteredServers.map((serverInfo) => (
<ServerCardInline
now={nezhaWsData.now}
key={serverInfo.id}
now={nezhaWsData.now}
serverInfo={serverInfo}
/>
))}
</section>
)}
{inline === "0" && (
<section
ref={containerRef}
className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6 server-card-list"
>
) : (
<section className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6 server-card-list">
{filteredServers.map((serverInfo) => (
<ServerCard
now={nezhaWsData.now}
key={serverInfo.id}
now={nezhaWsData.now}
serverInfo={serverInfo}
/>
))}
</section>
)
) : (
<ServerEmptyState filtered />
)}
</>
)}
{/* 只有在 status 为 'all' (即点击总服务器数时) 或者 activeView 为 'domains' 时,才显示域名列表 */}
{(status === 'all' || activeView === 'domains') && (
{(status === "all" || activeView === "domains") && (
<div className="mt-6">
<DomainStatus />
</div>
@@ -511,3 +643,4 @@ export default function Servers() {
</div>
);
}
+16 -16
View File
@@ -1,14 +1,18 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { NetworkChart } from "@/components/NetworkChart";
import { lazy, Suspense, useEffect, useState } from "react";
import { Navigate, useParams } from "react-router-dom";
import NetworkChartLoading from "@/components/NetworkChartLoading";
import ServerDetailChart from "@/components/ServerDetailChart";
import ServerDetailOverview from "@/components/ServerDetailOverview";
import TabSwitch from "@/components/TabSwitch";
import { Separator } from "@/components/ui/separator";
export default function ServerDetail() {
const navigate = useNavigate();
const NetworkChart = lazy(() =>
import("@/components/NetworkChart").then((module) => ({
default: module.NetworkChart,
})),
);
export default function ServerDetail() {
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}, []);
@@ -19,8 +23,7 @@ export default function ServerDetail() {
const { id: server_id } = useParams();
if (!server_id) {
navigate("/404");
return null;
return <Navigate to="/404" replace />;
}
return (
@@ -42,15 +45,12 @@ export default function ServerDetail() {
<ServerDetailSummary server_id={Number(server_id)} />
</section> */}
<div style={{ display: currentTab === tabs[0] ? "block" : "none" }}>
<ServerDetailChart server_id={server_id} />
</div>
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
<NetworkChart
server_id={Number(server_id)}
show={currentTab === tabs[1]}
/>
</div>
{currentTab === tabs[0] && <ServerDetailChart server_id={server_id} />}
{currentTab === tabs[1] && (
<Suspense fallback={<NetworkChartLoading />}>
<NetworkChart server_id={Number(server_id)} show={true} />
</Suspense>
)}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App";
import { createTestQueryClient } from "@/test/utils";
const appMocks = vi.hoisted(() => ({
backgroundImage: undefined as string | undefined,
fetchSetting: vi.fn(),
injectContext: vi.fn(),
setTheme: vi.fn(),
}));
vi.mock("../components/DashCommand", () => ({
DashCommand: () => <div>dash-command</div>,
}));
vi.mock("../components/Footer", () => ({
default: () => <footer>footer</footer>,
}));
vi.mock("../components/Header", () => ({
default: () => <header>header</header>,
RefreshToast: () => <div>refresh-toast</div>,
}));
vi.mock("../pages/Server", () => ({
default: ({ backendError }: { backendError?: Error | null }) => (
<div>
<div>server-page</div>
{backendError && <p>{backendError.message}</p>}
</div>
),
}));
vi.mock("../pages/ServerDetail", () => ({
default: () => <div>server-detail-page</div>,
}));
vi.mock("../hooks/use-background", () => ({
useBackground: () => ({ backgroundImage: appMocks.backgroundImage }),
}));
vi.mock("../hooks/use-theme", () => ({
useTheme: () => ({ setTheme: appMocks.setTheme }),
}));
vi.mock("../lib/inject", () => ({
InjectContext: appMocks.injectContext,
}));
vi.mock("../lib/nezha-api", () => ({
fetchSetting: appMocks.fetchSetting,
}));
function settingResponse(customCode = "") {
return {
success: true,
data: {
config: {
debug: false,
language: "zh-CN",
site_name: "Nezha",
user_template: "",
admin_template: "",
custom_code: customCode,
},
version: "1.0.0",
},
};
}
function renderApp(route = "/") {
window.history.pushState({}, "", route);
const queryClient = createTestQueryClient();
return {
queryClient,
...render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
),
};
}
describe("App", () => {
beforeEach(() => {
appMocks.backgroundImage = undefined;
appMocks.fetchSetting.mockResolvedValue(settingResponse());
appMocks.injectContext.mockResolvedValue(undefined);
});
it("renders the main shell after settings load and applies global theme/background settings", async () => {
Object.assign(window, {
ForceTheme: "dark",
CustomMobileBackgroundImage: "/mobile.png",
});
appMocks.backgroundImage = "/desktop.png";
const { container } = renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
expect(screen.getByText("refresh-toast")).toBeInTheDocument();
expect(screen.getByText("header")).toBeInTheDocument();
expect(screen.getByText("dash-command")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
expect(appMocks.setTheme).toHaveBeenCalledWith("dark");
expect(
Array.from(container.querySelectorAll<HTMLElement>("[style]")).some(
(element) => element.style.backgroundImage.includes("/desktop.png"),
),
).toBe(true);
expect(
Array.from(container.querySelectorAll<HTMLElement>("[style]")).some(
(element) => element.style.backgroundImage.includes("/mobile.png"),
),
).toBe(true);
});
it("renders the app shell while initial settings are still pending", async () => {
appMocks.fetchSetting.mockImplementation(
() => new Promise(() => undefined),
);
renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
expect(screen.getByText("refresh-toast")).toBeInTheDocument();
expect(screen.getByText("header")).toBeInTheDocument();
expect(screen.getByText("dash-command")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
});
it("injects custom code before showing the app shell", async () => {
appMocks.fetchSetting.mockResolvedValue(
settingResponse("<script>custom</script>"),
);
renderApp();
await waitFor(() => {
expect(appMocks.injectContext).toHaveBeenCalledWith(
"<script>custom</script>",
);
});
expect(await screen.findByText("server-page")).toBeInTheDocument();
});
it("renders the app shell when initial settings fetch fails", async () => {
appMocks.fetchSetting.mockRejectedValue(new Error("settings failed"));
renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
expect(screen.getByText("refresh-toast")).toBeInTheDocument();
expect(screen.getByText("header")).toBeInTheDocument();
expect(screen.getByText("dash-command")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
expect(await screen.findByText("settings failed")).toBeInTheDocument();
});
it("keeps rendering with stale settings when a later settings refetch fails", async () => {
let requestCount = 0;
appMocks.fetchSetting.mockImplementation(() => {
requestCount += 1;
return requestCount === 1
? Promise.resolve(settingResponse())
: Promise.reject(new Error("settings failed"));
});
const { queryClient } = renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
await queryClient.refetchQueries({ queryKey: ["setting"] });
await waitFor(() => {
expect(appMocks.fetchSetting.mock.calls.length).toBeGreaterThanOrEqual(2);
});
expect(screen.getByText("server-page")).toBeInTheDocument();
expect(screen.queryByText("settings failed")).not.toBeInTheDocument();
});
it("routes server detail paths through the app router", async () => {
renderApp("/server/42");
expect(await screen.findByText("server-detail-page")).toBeInTheDocument();
});
});
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import RemainPercentBar from "@/components/RemainPercentBar";
describe("RemainPercentBar", () => {
it("uses status colors for low, medium, and healthy remaining percentages", () => {
const { rerender } = render(<RemainPercentBar value={29} />);
const progress = screen.getByRole("progressbar");
const indicator = progress.firstElementChild;
expect(progress).toHaveClass("w-[70px]");
expect(indicator).toHaveClass("bg-red-500");
expect(indicator).toHaveStyle({ transform: "translateX(-71%)" });
rerender(<RemainPercentBar value={30} />);
expect(progress.firstElementChild).toHaveClass("bg-orange-400");
rerender(<RemainPercentBar value={70} />);
expect(progress.firstElementChild).toHaveClass("bg-green-500");
});
});
+144
View File
@@ -0,0 +1,144 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ThemeColorManager } from "@/components/ThemeColorManager";
import {
ThemeProvider,
ThemeProviderContext,
} from "@/components/ThemeProvider";
import { useTheme } from "@/hooks/use-theme";
function ThemeProbe() {
const { setTheme, theme } = useTheme();
return (
<div>
<p data-testid="theme-state">{theme}</p>
<button type="button" onClick={() => setTheme("dark")}>
dark
</button>
<button type="button" onClick={() => setTheme("light")}>
light
</button>
</div>
);
}
function InvalidThemeProbe() {
useTheme();
return null;
}
describe("ThemeProvider", () => {
it("applies theme classes, meta color, and localStorage state", async () => {
const user = userEvent.setup();
const meta = document.createElement("meta");
meta.name = "theme-color";
document.head.appendChild(meta);
render(
<ThemeProvider storageKey="theme-test">
<ThemeProbe />
</ThemeProvider>,
);
expect(screen.getByTestId("theme-state")).toHaveTextContent("system");
expect(document.documentElement).toHaveClass("light");
await user.click(screen.getByRole("button", { name: "dark" }));
expect(screen.getByTestId("theme-state")).toHaveTextContent("dark");
expect(localStorage.getItem("theme-test")).toBe("dark");
expect(document.documentElement).toHaveClass("dark");
expect(document.documentElement.style.colorScheme).toBe("dark");
expect(document.querySelector('meta[name="theme-color"]')).toHaveAttribute(
"content",
"hsl(30 15% 8%)",
);
await user.click(screen.getByRole("button", { name: "light" }));
expect(document.documentElement).toHaveClass("light");
});
it("uses stored theme value as the initial theme", () => {
localStorage.setItem("theme-test", "dark");
render(
<ThemeProvider storageKey="theme-test">
<ThemeProbe />
</ThemeProvider>,
);
expect(screen.getByTestId("theme-state")).toHaveTextContent("dark");
expect(document.documentElement).toHaveClass("dark");
});
it("follows system color scheme changes and cleans up its listener", () => {
let systemDark = true;
let changeListener: (() => void) | undefined;
const addEventListener = vi.fn((_event: string, listener: () => void) => {
changeListener = listener;
});
const removeEventListener = vi.fn();
vi.mocked(window.matchMedia).mockReturnValue({
get matches() {
return systemDark;
},
media: "(prefers-color-scheme: dark)",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener,
removeEventListener,
dispatchEvent: vi.fn(),
} as unknown as MediaQueryList);
const { unmount } = render(
<ThemeProvider storageKey="theme-system-test">
<ThemeProbe />
</ThemeProvider>,
);
expect(document.documentElement).toHaveClass("dark");
act(() => {
systemDark = false;
changeListener?.();
});
expect(document.documentElement).toHaveClass("light");
unmount();
expect(removeEventListener).toHaveBeenCalledWith("change", changeListener);
});
it("keeps theme-color meta in sync through ThemeColorManager", () => {
render(
<ThemeProvider storageKey="theme-test">
<ThemeColorManager />
<ThemeProbe />
</ThemeProvider>,
);
act(() => {
screen.getByRole("button", { name: "dark" }).click();
});
expect(document.querySelector('meta[name="theme-color"]')).toHaveAttribute(
"content",
"hsl(30 15% 8%)",
);
});
it("throws when the theme context is explicitly unavailable", () => {
expect(() =>
render(
<ThemeProviderContext.Provider value={undefined as never}>
<InvalidThemeProbe />
</ThemeProviderContext.Provider>,
),
).toThrow("useTheme must be used within a ThemeProvider");
});
});
@@ -0,0 +1,185 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import AnimateCountClient, { AnimateCount } from "@/components/AnimatedCount";
import ErrorBoundary from "@/components/ErrorBoundary";
import ChartSkeleton from "@/components/loading/ChartSkeleton";
import { Loader, LoadingSpinner } from "@/components/loading/Loader";
import NetworkChartLoading from "@/components/NetworkChartLoading";
import { SearchButton } from "@/components/SearchButton";
import ServerOverview from "@/components/ServerOverview";
import ServerUsageBar from "@/components/ServerUsageBar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { CommandProvider } from "@/context/command-provider";
import { StatusProvider } from "@/context/status-provider";
import { useCommand } from "@/hooks/use-command";
import { useStatus } from "@/hooks/use-status";
function BrokenComponent(): never {
throw new Error("render failed");
}
function CommandReadout() {
const { isOpen } = useCommand();
return <p>{isOpen ? "command-open" : "command-closed"}</p>;
}
function StatusReadout() {
const { status } = useStatus();
return <p>{`status:${status}`}</p>;
}
describe("basic display components", () => {
it("renders animated counts with padded digits", () => {
render(<AnimateCount minDigits={3}>{7}</AnimateCount>);
expect(screen.getAllByText("0")).toHaveLength(4);
expect(screen.getAllByText("7")).toHaveLength(1);
});
it("keeps the animated count client stable across count changes", () => {
const { rerender } = render(
<AnimateCountClient count={1} minDigits={2} className="count" />,
);
expect(screen.getAllByText("1")).toHaveLength(2);
rerender(<AnimateCountClient count={2} minDigits={2} className="count" />);
expect(screen.getByText("2")).toBeInTheDocument();
});
it("renders server usage colors for healthy, warning, and critical values", () => {
const { rerender } = render(<ServerUsageBar value={70} />);
expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
"bg-green-500",
);
rerender(<ServerUsageBar value={71} />);
expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
"bg-orange-400",
);
rerender(<ServerUsageBar value={91} />);
expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
"bg-red-500",
);
});
it("renders loading primitives", () => {
const { container } = render(
<>
<Loader visible={true} />
<LoadingSpinner />
<ChartSkeleton width={120} height="40%" />
<NetworkChartLoading />
</>,
);
expect(
container.querySelector("[data-visible='true']"),
).toBeInTheDocument();
expect(container.querySelectorAll(".hamster-loading-bar")).toHaveLength(16);
expect(container.querySelector(".animate-spin")).toBeInTheDocument();
expect(container.querySelector(".h-\\[250px\\]")).toBeInTheDocument();
});
it("catches render errors with ErrorBoundary", () => {
render(
<ErrorBoundary>
<BrokenComponent />
</ErrorBoundary>,
);
expect(screen.getByText("500")).toBeInTheDocument();
expect(screen.getByText("render failed")).toBeInTheDocument();
});
});
describe("interactive app controls", () => {
it("opens command search from the search button", async () => {
const user = userEvent.setup();
render(
<CommandProvider>
<SearchButton />
<CommandReadout />
</CommandProvider>,
);
expect(screen.getByText("command-closed")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Search" }));
expect(screen.getByText("command-open")).toBeInTheDocument();
});
it("lets overview cards update the global status filter", () => {
render(
<StatusProvider>
<ServerOverview
total={3}
online={2}
offline={1}
up={1024}
down={2048}
upSpeed={512}
downSpeed={256}
totalDomains={0}
onViewChange={() => {}}
activeView="servers"
/>
<StatusReadout />
</StatusProvider>,
);
expect(screen.getByText("status:all")).toBeInTheDocument();
fireEvent.click(screen.getByText("serverOverview.onlineServers"));
expect(screen.getByText("status:online")).toBeInTheDocument();
fireEvent.click(screen.getByText("serverOverview.offlineServers"));
expect(screen.getByText("status:offline")).toBeInTheDocument();
fireEvent.click(screen.getByText("serverOverview.totalServers"));
expect(screen.getByText("status:all")).toBeInTheDocument();
});
});
describe("ui primitives", () => {
it("forwards classes and events through Button and Input", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const onChange = vi.fn();
render(
<>
<Button variant="secondary" size="sm" onClick={onClick}>
Save
</Button>
<Input placeholder="Name" onChange={onChange} />
</>,
);
await user.click(screen.getByRole("button", { name: "Save" }));
await user.type(screen.getByPlaceholderText("Name"), "edge");
expect(onClick).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Save" })).toHaveClass(
"bg-secondary",
);
});
it("renders skeletons and separators with expected orientation classes", () => {
const { container } = render(
<>
<Skeleton data-testid="skeleton" className="h-4" />
<Separator data-testid="horizontal" />
<Separator data-testid="vertical" orientation="vertical" />
</>,
);
expect(screen.getByTestId("skeleton")).toHaveClass("animate-pulse", "h-4");
expect(screen.getByTestId("horizontal")).toHaveClass("h-px", "w-full");
expect(screen.getByTestId("vertical")).toHaveClass("h-full", "w-px");
expect(container.querySelectorAll("[data-orientation]")).toHaveLength(2);
});
});
@@ -0,0 +1,136 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
ChartContainer,
ChartLegendContent,
ChartTooltipContent,
} from "@/components/ui/chart";
function CpuIcon() {
return <span data-testid="cpu-icon">icon</span>;
}
const chartConfig = {
cpu: {
label: "CPU Usage",
icon: CpuIcon,
},
mem: {
label: "Memory",
color: "#00ff00",
},
themed: {
label: "Themed",
theme: {
light: "#ffffff",
dark: "#000000",
},
},
};
describe("chart primitives", () => {
it("renders tooltip rows with config labels, icons, and formatted values", () => {
const formatter = vi.fn((value: number, name: string) => (
<span>{`${name}:${value.toFixed(1)}`}</span>
));
render(
<ChartContainer config={chartConfig}>
<ChartTooltipContent
active={true}
label="cpu"
payload={[
{
color: "#ff0000",
dataKey: "cpu",
name: "cpu",
payload: { fill: "#ff0000" },
value: 12.345,
},
{
color: "#00ff00",
dataKey: "mem",
name: "mem",
payload: { fill: "#00ff00" },
value: 42,
},
]}
formatter={formatter}
/>
</ChartContainer>,
);
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
expect(screen.getByText("cpu:12.3")).toBeInTheDocument();
expect(screen.getByText("mem:42.0")).toBeInTheDocument();
expect(formatter).toHaveBeenCalled();
});
it("renders nested labels for single line indicators and hides inactive tooltips", () => {
const { container, rerender } = render(
<ChartContainer config={chartConfig}>
<ChartTooltipContent
active={true}
indicator="line"
label="cpu"
payload={[
{
color: "#ff0000",
dataKey: "cpu",
name: "cpu",
payload: { fill: "#ff0000" },
value: 12,
},
]}
/>
</ChartContainer>,
);
expect(screen.getAllByText("CPU Usage")).toHaveLength(2);
expect(screen.getByText("12.00")).toBeInTheDocument();
rerender(
<ChartContainer config={chartConfig}>
<ChartTooltipContent active={false} payload={[]} />
</ChartContainer>,
);
expect(container).not.toHaveTextContent("CPU Usage");
});
it("renders legend payloads and chart theme style blocks", () => {
const { container, rerender } = render(
<ChartContainer id="summary" config={chartConfig}>
<ChartLegendContent
verticalAlign="top"
payload={[
{
color: "#ff0000",
dataKey: "cpu",
value: "cpu",
},
{
color: "#00ff00",
dataKey: "mem",
value: "mem",
},
]}
/>
</ChartContainer>,
);
expect(screen.getByTestId("cpu-icon")).toBeInTheDocument();
expect(screen.getByText("mem")).toBeInTheDocument();
expect(container.querySelector("style")?.textContent).toContain(
"--color-themed",
);
rerender(
<ChartContainer config={chartConfig}>
<ChartLegendContent payload={[]} />
</ChartContainer>,
);
expect(screen.queryByText("mem")).not.toBeInTheDocument();
});
});
+118
View File
@@ -0,0 +1,118 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashCommand } from "@/components/DashCommand";
import { createServer } from "@/test/fixtures";
const dashMocks = vi.hoisted(() => ({
closeCommand: vi.fn(),
isOpen: true,
lastData: null as {
now: number;
servers: ReturnType<typeof createServer>[];
} | null,
navigate: vi.fn(),
setTheme: vi.fn(),
toggleCommand: vi.fn(),
connected: true,
}));
vi.mock("@/hooks/use-command", () => ({
useCommand: () => ({
closeCommand: dashMocks.closeCommand,
isOpen: dashMocks.isOpen,
toggleCommand: dashMocks.toggleCommand,
}),
}));
vi.mock("@/hooks/use-theme", () => ({
useTheme: () => ({
setTheme: dashMocks.setTheme,
}),
}));
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: dashMocks.connected,
lastData: dashMocks.lastData,
}),
}));
vi.mock("react-router-dom", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router-dom")>();
return {
...actual,
useNavigate: () => dashMocks.navigate,
};
});
function seedWebSocketData() {
dashMocks.connected = true;
dashMocks.lastData = {
now: Date.parse("2025-01-01T00:00:20.000Z"),
servers: [
createServer({
id: 7,
name: "edge-7",
last_active: "2025-01-01T00:00:00.000Z",
}),
],
};
}
describe("DashCommand", () => {
beforeEach(() => {
dashMocks.closeCommand.mockClear();
dashMocks.navigate.mockClear();
dashMocks.setTheme.mockClear();
dashMocks.toggleCommand.mockClear();
dashMocks.isOpen = true;
dashMocks.connected = true;
dashMocks.lastData = null;
});
it("does not render until websocket data is available", () => {
dashMocks.connected = false;
dashMocks.lastData = null;
const { container } = render(<DashCommand />);
expect(container).toBeEmptyDOMElement();
});
it("does not mount server command items while the dialog is closed", () => {
seedWebSocketData();
dashMocks.isOpen = false;
render(<DashCommand />);
expect(screen.queryByText("edge-7")).not.toBeInTheDocument();
});
it("renders server and shortcut commands, handles selection, and listens for keyboard toggle", async () => {
const user = userEvent.setup();
seedWebSocketData();
render(<DashCommand />);
fireEvent.keyDown(document, { key: "k", metaKey: true });
expect(dashMocks.toggleCommand).toHaveBeenCalledOnce();
expect(screen.getByPlaceholderText("TypeCommand")).toBeInTheDocument();
expect(screen.getByText("edge-7")).toBeInTheDocument();
expect(screen.getByText("ToggleDarkMode")).toBeInTheDocument();
Object.defineProperty(window, "scrollY", {
configurable: true,
value: 256,
});
await user.click(screen.getByText("edge-7"));
expect(dashMocks.navigate).toHaveBeenCalledWith("/server/7");
expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(sessionStorage.getItem("scrollPosition")).toBe("256");
expect(dashMocks.closeCommand).toHaveBeenCalled();
await user.click(screen.getByText("ToggleDarkMode"));
expect(dashMocks.setTheme).toHaveBeenCalledWith("dark");
});
});
+176
View File
@@ -0,0 +1,176 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router-dom";
import { describe, expect, it } from "vitest";
import GlobalMap, { InteractiveMap } from "@/components/GlobalMap";
import { TooltipProvider } from "@/context/tooltip-provider";
import { createServer } from "@/test/fixtures";
type InteractiveMapProps = Parameters<typeof InteractiveMap>[0];
const now = Date.parse("2025-01-01T00:00:20.000Z");
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
function renderMap(ui: React.ReactElement) {
return render(
<MemoryRouter initialEntries={["/"]}>
<TooltipProvider>
{ui}
<LocationProbe />
</TooltipProvider>
</MemoryRouter>,
);
}
function mapFeature(code: string, name: string) {
return {
type: "Feature",
properties: {
iso_a2_eh: code,
iso_a3_eh: `${code}A`,
name,
},
geometry: {
type: "Polygon",
coordinates: [
[
[-100, 30],
[-90, 30],
[-90, 40],
[-100, 40],
[-100, 30],
],
],
},
};
}
describe("GlobalMap", () => {
it("counts unique countries and ignores servers without country codes", () => {
Object.assign(window, { CustomBackgroundImage: "/background.png" });
const { container } = renderMap(
<GlobalMap
now={now}
serverList={[
createServer({ country_code: "us" }),
createServer({ id: 2, country_code: "US" }),
createServer({ id: 3, country_code: "sg" }),
createServer({ id: 4, country_code: "" }),
]}
/>,
);
expect(
screen.getByText(/map\.Distributions 2 map\.Regions/),
).toBeInTheDocument();
expect(container.querySelector("section")).toHaveClass("bg-card/70");
});
});
describe("InteractiveMap", () => {
it("shows country tooltips, navigates from servers, and clears tooltip state", async () => {
const user = userEvent.setup();
const filteredFeatures = [
mapFeature("US", "United States"),
mapFeature("DE", "Germany"),
] as unknown as InteractiveMapProps["filteredFeatures"];
const { container } = renderMap(
<InteractiveMap
countries={["US", "SG"]}
serverCounts={{ US: 2, SG: 1 }}
width={900}
height={500}
filteredFeatures={filteredFeatures}
countryServers={{
US: [
{
id: 11,
name: "us-online",
status: true,
},
{
id: 12,
name: "us-offline",
status: false,
},
],
SG: [
{
id: 13,
name: "sg-edge",
status: true,
},
],
}}
/>,
);
const highlightedCountry = container.querySelector("path.fill-green-700");
expect(highlightedCountry).toBeInTheDocument();
fireEvent.mouseEnter(highlightedCountry as SVGPathElement);
expect(await screen.findByText("United States")).toBeInTheDocument();
expect(screen.getByText("2 map.Servers")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /us-offline/ }));
expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(screen.getByText("/server/12")).toBeInTheDocument();
const fallbackMarker = container.querySelector("circle.fill-sky-700");
expect(fallbackMarker).toBeInTheDocument();
fireEvent.mouseEnter(fallbackMarker as SVGCircleElement);
expect(await screen.findByText("Singapore")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /sg-edge/ })).toBeInTheDocument();
fireEvent.mouseLeave(container.querySelector(".relative") as HTMLElement);
expect(screen.queryByText("Singapore")).not.toBeInTheDocument();
});
it("clears tooltip state for non-highlighted countries and empty map areas", () => {
const filteredFeatures = [
mapFeature("US", "United States"),
mapFeature("DE", "Germany"),
] as unknown as InteractiveMapProps["filteredFeatures"];
const { container } = renderMap(
<InteractiveMap
countries={["US"]}
serverCounts={{ US: 1 }}
width={900}
height={500}
filteredFeatures={filteredFeatures}
countryServers={{
US: [
{
id: 11,
name: "us-online",
status: true,
},
],
}}
/>,
);
const [highlightedCountry, neutralCountry] = Array.from(
container.querySelectorAll("path"),
);
fireEvent.mouseEnter(highlightedCountry as SVGPathElement);
expect(screen.getByText("United States")).toBeInTheDocument();
fireEvent.mouseEnter(neutralCountry as SVGPathElement);
expect(screen.queryByText("United States")).not.toBeInTheDocument();
fireEvent.mouseEnter(container.querySelector("rect") as SVGRectElement);
expect(screen.queryByText("United States")).not.toBeInTheDocument();
});
});
+252
View File
@@ -0,0 +1,252 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactElement } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Header, { RefreshToast } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider";
import { CommandProvider } from "@/context/command-provider";
import { createSettingResponse } from "@/test/fixtures";
import { createTestQueryClient } from "@/test/utils";
const headerMocks = vi.hoisted(() => ({
backgroundImage: undefined as string | undefined,
connected: true,
fetchLoginUser: vi.fn(),
fetchSetting: vi.fn(),
lastData: null as { now: number; online?: number; servers: [] } | null,
needReconnect: false,
setNeedReconnect: vi.fn(),
updateBackground: vi.fn(),
}));
vi.mock("@/hooks/use-background", () => ({
useBackground: () => ({
backgroundImage: headerMocks.backgroundImage,
updateBackground: headerMocks.updateBackground,
}),
}));
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: headerMocks.connected,
lastData: headerMocks.lastData,
needReconnect: headerMocks.needReconnect,
setNeedReconnect: headerMocks.setNeedReconnect,
}),
}));
vi.mock("@/lib/nezha-api", () => ({
fetchLoginUser: headerMocks.fetchLoginUser,
fetchSetting: headerMocks.fetchSetting,
}));
function settingResponse(siteName = "Nezha") {
return {
...createSettingResponse(),
data: {
...createSettingResponse().data,
config: {
...createSettingResponse().data.config,
site_name: siteName,
},
},
};
}
function loginResponse() {
return {
success: true,
data: {
id: 1,
username: "admin",
password: "",
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
},
};
}
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
function renderHeader(route = "/server/1") {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[route]}>
<ThemeProvider storageKey="header-theme-test">
<CommandProvider>
<Header />
<LocationProbe />
</CommandProvider>
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
function renderInRouter(ui: ReactElement) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
}
describe("Header", () => {
beforeEach(() => {
headerMocks.backgroundImage = undefined;
headerMocks.connected = true;
headerMocks.fetchLoginUser.mockReset();
headerMocks.fetchSetting.mockReset();
headerMocks.lastData = {
now: Date.parse("2025-01-01T00:00:20.000Z"),
online: 4,
servers: [],
};
headerMocks.needReconnect = false;
headerMocks.setNeedReconnect.mockReset();
headerMocks.updateBackground.mockReset();
Object.assign(window, {
CustomBackgroundImage: "",
CustomDesc: "",
CustomLinks: "",
CustomLogo: "",
CustomMobileBackgroundImage: "",
});
headerMocks.fetchSetting.mockResolvedValue(settingResponse());
headerMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
});
it("renders configured site identity, custom links, online count, and dashboard state", async () => {
const user = userEvent.setup();
Object.assign(window, {
CustomDesc: "edge status",
CustomLinks: JSON.stringify([
{ link: "https://example.test", name: "Docs" },
]),
CustomLogo: "/logo.png",
});
Object.defineProperty(document, "cookie", {
configurable: true,
value: "session=1",
});
sessionStorage.setItem("selectedGroup", "Edge");
headerMocks.fetchSetting.mockResolvedValue(settingResponse("Status Hub"));
headerMocks.fetchLoginUser.mockResolvedValue(loginResponse());
renderHeader();
const siteName = await screen.findByText("Status Hub");
expect(screen.getByText("edge status")).toBeInTheDocument();
expect(screen.getByAltText("apple-touch-icon")).toHaveAttribute(
"src",
"/logo.png",
);
expect(screen.getAllByRole("link", { name: "Docs" })).toHaveLength(2);
expect(await screen.findAllByText("dashboard")).toHaveLength(2);
expect(screen.getByText("online").closest("button")).toHaveTextContent("4");
expect(screen.getByText("online")).toBeInTheDocument();
await waitFor(() => {
expect(document.title).toBe("Status Hub");
});
expect(document.querySelector("link[rel='shortcut icon']")).toHaveAttribute(
"href",
"/logo.png",
);
await user.click(siteName);
expect(sessionStorage.getItem("selectedGroup")).toBeNull();
expect(screen.getByText("/")).toBeInTheDocument();
});
it("uses the offline display and login links when websocket and auth are unavailable", async () => {
headerMocks.connected = false;
const { container } = renderHeader();
expect(await screen.findAllByText("login")).toHaveLength(2);
expect(screen.getByText("offline")).toBeInTheDocument();
expect(
container.querySelector("[data-visible='true']"),
).toBeInTheDocument();
});
it("ignores invalid custom links instead of crashing", async () => {
Object.assign(window, {
CustomLinks: "{bad-json",
});
renderHeader();
expect(await screen.findByText("Nezha")).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Docs" }),
).not.toBeInTheDocument();
});
it("stores and removes the active custom background", async () => {
const user = userEvent.setup();
headerMocks.backgroundImage = "/desktop.png";
Object.assign(window, {
CustomBackgroundImage: "/desktop.png",
CustomMobileBackgroundImage: "/mobile.png",
});
const { container } = renderHeader();
await screen.findByText("Nezha");
const toggleButton = container
.querySelector(".lucide-image-minus")
?.closest("button");
expect(toggleButton).toBeInTheDocument();
await user.click(toggleButton as HTMLButtonElement);
expect(sessionStorage.getItem("savedBackgroundImage")).toBe("/desktop.png");
expect(headerMocks.updateBackground).toHaveBeenCalledWith(undefined);
});
it("restores the saved custom background", async () => {
const user = userEvent.setup();
sessionStorage.setItem("savedBackgroundImage", "/saved.png");
const { container } = renderHeader();
await screen.findByText("Nezha");
const toggleButton = container
.querySelector(".lucide-image-minus")
?.closest("button");
expect(toggleButton).toBeInTheDocument();
await user.click(toggleButton as HTMLButtonElement);
expect(headerMocks.updateBackground).toHaveBeenCalledWith("/saved.png");
});
});
describe("RefreshToast", () => {
beforeEach(() => {
headerMocks.needReconnect = false;
});
it("renders only while reconnect refresh is needed", () => {
vi.useFakeTimers();
sessionStorage.setItem("needRefresh", "true");
const { container, rerender } = renderInRouter(<RefreshToast />);
expect(container).toBeEmptyDOMElement();
headerMocks.needReconnect = true;
rerender(
<MemoryRouter>
<RefreshToast />
</MemoryRouter>,
);
expect(screen.getByText("refreshing...")).toBeInTheDocument();
expect(sessionStorage.getItem("needRefresh")).toBeNull();
});
});
@@ -0,0 +1,232 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useEffect } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import BillingInfo from "@/components/billingInfo";
import MapTooltip from "@/components/MapTooltip";
import PlanInfo from "@/components/PlanInfo";
import ServerFlag from "@/components/ServerFlag";
import { TooltipProvider } from "@/context/tooltip-provider";
import { useTooltip } from "@/hooks/use-tooltip";
import type { PublicNoteData } from "@/lib/utils";
const planData: PublicNoteData = {
planDataMod: {
bandwidth: "1Gbps",
trafficVol: "2TB",
trafficType: "monthly",
IPv4: "1",
IPv6: "1",
networkRoute: "CN2,CMI",
extra: "Premium,Backup",
},
};
function TooltipSeeder() {
const { setTooltipData } = useTooltip();
useEffect(() => {
setTooltipData({
centroid: [12, 34],
country: "China",
count: 2,
servers: [
{ id: 1, name: "edge-1", status: true },
{ id: 2, name: "edge-2", status: false },
],
});
}, [setTooltipData]);
return null;
}
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("PlanInfo and BillingInfo", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
});
it("renders plan badges for every supported public note field", () => {
render(<PlanInfo parsedData={planData} />);
expect(screen.getByText("1Gbps")).toBeInTheDocument();
expect(screen.getByText("2TB")).toBeInTheDocument();
expect(screen.getByText("IPv4")).toBeInTheDocument();
expect(screen.getByText("IPv6")).toBeInTheDocument();
expect(screen.getByText("CN2CMI")).toBeInTheDocument();
expect(screen.getByText("Premium")).toBeInTheDocument();
expect(screen.getByText("Backup")).toBeInTheDocument();
});
it("renders active, free, usage-based, indefinite, and expired billing states", () => {
const { rerender } = render(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
autoRenewal: "0",
cycle: "monthly",
amount: "10",
},
}}
/>,
);
expect(
screen.getByText("billingInfo.price: 10/monthly"),
).toBeInTheDocument();
expect(screen.getByText(/billingInfo.remaining: 16/)).toBeInTheDocument();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
rerender(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "",
endDate: "",
autoRenewal: "0",
cycle: "",
amount: "0",
},
}}
/>,
);
expect(screen.getByText("billingInfo.free")).toBeInTheDocument();
rerender(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "",
endDate: "",
autoRenewal: "0",
cycle: "",
amount: "-1",
},
}}
/>,
);
expect(screen.getByText("billingInfo.usage-baseed")).toBeInTheDocument();
rerender(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "2025-01-01",
endDate: "0000-00-00",
autoRenewal: "0",
cycle: "",
amount: "",
},
}}
/>,
);
expect(screen.getByText(/billingInfo.indefinite/)).toBeInTheDocument();
rerender(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "2024-01-01",
endDate: "2024-01-31",
autoRenewal: "0",
cycle: "monthly",
amount: "10",
},
}}
/>,
);
expect(screen.getByText(/billingInfo.expired/)).toBeInTheDocument();
});
});
describe("MapTooltip", () => {
it("renders tooltip data and navigates to selected servers", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/"]}>
<TooltipProvider>
<TooltipSeeder />
<MapTooltip />
<LocationProbe />
</TooltipProvider>
</MemoryRouter>,
);
expect(await screen.findByText("Mainland China")).toBeInTheDocument();
expect(screen.getByTestId("map-tooltip")).toHaveStyle({
transform: "translate(20%, 8px)",
});
expect(screen.getByText("2 map.Servers")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /edge-2/ }));
expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(screen.getByText("/server/2")).toBeInTheDocument();
});
});
describe("ServerFlag", () => {
it("uses SVG flag classes when emoji flags are forced off", () => {
Object.assign(window, { ForceUseSvgFlag: true });
const { container } = render(<ServerFlag country_code="us" />);
expect(container.querySelector(".fi-us")).toBeInTheDocument();
});
it("normalizes SVG flag classes and ignores invalid country codes", () => {
Object.assign(window, { ForceUseSvgFlag: true });
const { container, rerender } = render(<ServerFlag country_code="US" />);
expect(container.querySelector(".fi-us")).toBeInTheDocument();
rerender(<ServerFlag country_code="u" />);
expect(container).toBeEmptyDOMElement();
});
it("uses emoji flags when the canvas probe detects support", async () => {
Object.assign(window, { ForceUseSvgFlag: false });
const originalCreateElement = document.createElement.bind(document);
const canvasContext = {
fillStyle: "",
textBaseline: "",
font: "",
fillText: vi.fn(),
getImageData: vi.fn(() => ({
data: new Uint8ClampedArray([0, 0, 0, 255]),
})),
} as unknown as CanvasRenderingContext2D;
vi.spyOn(document, "createElement").mockImplementation(
(tagName, options) => {
if (tagName === "canvas") {
return {
getContext: vi.fn(() => canvasContext),
} as unknown as HTMLCanvasElement;
}
return originalCreateElement(tagName, options);
},
);
const { container } = render(<ServerFlag country_code="US" />);
await waitFor(() => {
expect(container).toHaveTextContent("🇺🇸");
});
expect(container.querySelector(".fi-US")).not.toBeInTheDocument();
});
it("renders nothing for missing country codes", () => {
Object.assign(window, { ForceUseSvgFlag: true });
const { container } = render(<ServerFlag country_code="" />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,160 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Footer from "@/components/Footer";
import GroupSwitch from "@/components/GroupSwitch";
import {
ServerDetailChartLoading,
ServerDetailLoading,
} from "@/components/loading/ServerDetailLoading";
import TabSwitch from "@/components/TabSwitch";
import { createTestQueryClient, renderWithProviders } from "@/test/utils";
const apiMocks = vi.hoisted(() => ({
fetchSetting: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => apiMocks);
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("Footer", () => {
beforeEach(() => {
apiMocks.fetchSetting.mockResolvedValue({
success: true,
data: {
config: {
debug: false,
language: "en-US",
site_name: "Nezha",
user_template: "",
admin_template: "",
custom_code: "",
},
version: "9.9.9",
},
});
});
it("renders setting version and project attribution", async () => {
renderWithProviders(<Footer />);
expect(screen.getByText("footer.themeBy")).toBeInTheDocument();
expect(screen.getByText("nezha-dash")).toHaveAttribute(
"href",
"https://github.com/hamster1963/nezha-dash",
);
expect(await screen.findByText("9.9.9")).toBeInTheDocument();
});
});
describe("Tab and group switches", () => {
it("switches tabs and marks the active tab", async () => {
const user = userEvent.setup();
const setCurrentTab = vi.fn();
render(
<TabSwitch
tabs={["Detail", "Network"]}
currentTab="Detail"
setCurrentTab={setCurrentTab}
/>,
);
await user.click(screen.getByText("tabSwitch.Network"));
expect(setCurrentTab).toHaveBeenCalledWith("Network");
expect(
screen.getByText("tabSwitch.Detail").parentElement?.parentElement,
).toHaveClass("text-black");
});
it("restores saved groups and hides itself when only All exists", async () => {
const setCurrentTab = vi.fn();
sessionStorage.setItem("selectedGroup", "Edge");
const { container, rerender } = render(
<GroupSwitch
tabs={["All", "Edge", "Asia"]}
currentTab="All"
setCurrentTab={setCurrentTab}
/>,
);
await waitFor(() => {
expect(setCurrentTab).toHaveBeenCalledWith("Edge");
});
rerender(
<GroupSwitch
tabs={["All"]}
currentTab="All"
setCurrentTab={setCurrentTab}
/>,
);
expect(container).toBeEmptyDOMElement();
});
it("scrolls overflowing group tabs and applies custom background styling", async () => {
const user = userEvent.setup();
const setCurrentTab = vi.fn();
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(320);
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(120);
window.CustomBackgroundImage = "/background.jpg";
const { container } = render(
<GroupSwitch
tabs={["All", "Edge", "Asia"]}
currentTab="All"
setCurrentTab={setCurrentTab}
/>,
);
const scrollContainer = container.querySelector(
".scrollbar-hidden",
) as HTMLElement;
fireEvent.wheel(scrollContainer, { deltaY: 42 });
await user.click(screen.getByText("Asia"));
expect(scrollContainer.scrollLeft).toBe(42);
expect(setCurrentTab).toHaveBeenCalledWith("Asia");
expect(scrollIntoView).not.toHaveBeenCalled();
expect(
container.querySelector(".relative.flex.items-center")?.className,
).toContain("bg-stone-100/70");
});
});
describe("server detail loading states", () => {
it("renders chart skeleton cards", () => {
const { container } = render(<ServerDetailChartLoading />);
expect(container.querySelectorAll(".h-\\[182px\\]")).toHaveLength(6);
});
it("navigates home from the detail loading back affordance", async () => {
const user = userEvent.setup();
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/server/7"]}>
<ServerDetailLoading />
<LocationProbe />
</MemoryRouter>
</QueryClientProvider>,
);
await user.click(screen.getAllByAltText("BackIcon")[0]);
expect(screen.getByText("/")).toBeInTheDocument();
});
});
+287
View File
@@ -0,0 +1,287 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactElement, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NetworkChart, NetworkChartClient } from "@/components/NetworkChart";
import type { ChartConfig } from "@/components/ui/chart";
import { createTestQueryClient } from "@/test/utils";
import type { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api";
const apiMocks = vi.hoisted(() => ({
fetchLoginUser: vi.fn(),
fetchMonitor: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => ({
fetchLoginUser: apiMocks.fetchLoginUser,
fetchMonitor: apiMocks.fetchMonitor,
}));
vi.mock("recharts", () => {
const createElement =
(testId: string) =>
({
children,
data,
dataKey,
}: {
children?: ReactNode;
data?: unknown[];
dataKey?: string;
}) => (
<div data-key={dataKey} data-points={data?.length} data-testid={testId}>
{children}
</div>
);
const ComposedChart = createElement("composed-chart");
const genericChart = createElement("generic-chart");
return {
Area: createElement("area"),
AreaChart: genericChart,
BarChart: genericChart,
CartesianGrid: createElement("grid"),
ComposedChart,
FunnelChart: genericChart,
Legend: ({ content }: { content?: ReactNode }) => (
<div data-testid="chart-legend">{content}</div>
),
Line: createElement("line"),
LineChart: genericChart,
PieChart: genericChart,
RadarChart: genericChart,
RadialBarChart: genericChart,
ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
<div data-testid="responsive-chart">{children}</div>
),
Sankey: genericChart,
ScatterChart: genericChart,
Tooltip: ({ content }: { content?: ReactNode }) => (
<div data-testid="chart-tooltip">{content}</div>
),
Treemap: genericChart,
XAxis: createElement("x-axis"),
YAxis: createElement("y-axis"),
};
});
const times = Array.from(
{ length: 12 },
(_, index) => Date.parse("2025-01-01T00:00:00.000Z") + index * 60 * 60 * 1000,
);
const monitorData: NezhaMonitor[] = [
{
monitor_id: 2,
monitor_name: "Beta",
display_index: 1,
server_id: 7,
server_name: "edge-chart",
created_at: times,
avg_delay: [15, 18, 0, 45, 48, 52, 4000, 65, 70, 75, 80, 85],
},
{
monitor_id: 1,
monitor_name: "Alpha",
display_index: 3,
server_id: 7,
server_name: "edge-chart",
created_at: times,
avg_delay: [30, 32, 35, 36, 38, 40, 42, 44, 46, 48, 50, 52],
packet_loss: [0, 0, 1, 1, 2, 2, 2, 3, 3, 4, 4, 5],
},
];
const clientChartData: ServerMonitorChart = {
Alpha: times.map((created_at, index) => ({
created_at,
avg_delay: 30 + index,
packet_loss: index,
})),
Beta: times.map((created_at, index) => ({
created_at,
avg_delay: 60 + index,
packet_loss: index % 2,
})),
};
const clientFormattedData = times.map((created_at, index) => ({
created_at,
Alpha: 30 + index,
Alpha_packet_loss: index,
Beta: 60 + index,
Beta_packet_loss: index % 2,
}));
const chartConfig = {
avg_delay: { label: "monitor.avgDelay" },
Alpha: { label: "Alpha" },
Beta: { label: "Beta" },
} satisfies ChartConfig;
function loginResponse() {
return {
success: true,
data: {
id: 1,
username: "admin",
password: "",
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
},
};
}
function renderWithQuery(ui: ReactElement) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
}
describe("NetworkChart", () => {
beforeEach(() => {
apiMocks.fetchLoginUser.mockReset();
apiMocks.fetchMonitor.mockReset();
apiMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
Object.defineProperty(document, "cookie", {
configurable: true,
value: "",
});
});
it("renders the loading state while monitor data is unavailable", () => {
apiMocks.fetchMonitor.mockReturnValue(new Promise(() => undefined));
const { container } = renderWithQuery(
<NetworkChart server_id={7} show={false} />,
);
expect(container.querySelector(".h-\\[250px\\]")).toBeInTheDocument();
expect(apiMocks.fetchMonitor).not.toHaveBeenCalled();
});
it("renders the no-data state from the monitor API", async () => {
apiMocks.fetchMonitor.mockResolvedValue({
success: true,
data: null,
});
renderWithQuery(<NetworkChart server_id={7} show={true} />);
expect(await screen.findByText("monitor.noData")).toBeInTheDocument();
});
it("fetches monitor data, transforms chart series, and allows logged-in period changes", async () => {
const user = userEvent.setup();
Object.defineProperty(document, "cookie", {
configurable: true,
value: "session=1",
});
apiMocks.fetchLoginUser.mockResolvedValue(loginResponse());
apiMocks.fetchMonitor.mockResolvedValue({
success: true,
data: monitorData,
});
renderWithQuery(<NetworkChart server_id={7} show={true} />);
expect(await screen.findByText("edge-chart")).toBeInTheDocument();
expect(apiMocks.fetchMonitor).toHaveBeenCalledWith(7, "1d");
expect(screen.getByText("2 monitor.monitorCount")).toBeInTheDocument();
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByTestId("composed-chart")).toHaveAttribute(
"data-points",
"12",
);
await user.click(screen.getByText("monitor.period7d"));
await waitFor(() => {
expect(apiMocks.fetchMonitor).toHaveBeenCalledWith(7, "7d");
});
});
});
describe("NetworkChartClient", () => {
it("locks longer periods for anonymous users and manages chart selection state", async () => {
const user = userEvent.setup();
const onPeriodChange = vi.fn();
render(
<NetworkChartClient
chartDataKey={["Alpha", "Beta"]}
chartConfig={chartConfig}
chartData={clientChartData}
serverName="edge-client"
formattedData={clientFormattedData}
isPeriodLoading={false}
period="1d"
onPeriodChange={onPeriodChange}
isLogin={false}
/>,
);
expect(screen.getByText("edge-client")).toBeInTheDocument();
expect(screen.getByText("2 monitor.monitorCount")).toBeInTheDocument();
await user.click(screen.getByText("monitor.period7d"));
expect(onPeriodChange).not.toHaveBeenCalled();
await user.click(screen.getByText("Alpha"));
expect(
screen.getByRole("button", { name: /monitor.clearSelections/ }),
).toBeInTheDocument();
expect(screen.getByTestId("area")).toHaveAttribute(
"data-key",
"packet_loss",
);
await user.click(screen.getByText("Beta"));
expect(screen.queryByTestId("area")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /monitor.clearSelections/ }),
);
expect(
screen.queryByRole("button", { name: /monitor.clearSelections/ }),
).not.toBeInTheDocument();
});
it("shows period loading and honors the forced peak-cut global", async () => {
const user = userEvent.setup();
Object.assign(window, { ForcePeakCutEnabled: true });
const onPeriodChange = vi.fn();
const { container } = render(
<NetworkChartClient
chartDataKey={["Alpha", "Beta"]}
chartConfig={chartConfig}
chartData={clientChartData}
serverName="edge-client"
formattedData={clientFormattedData}
isPeriodLoading={true}
period="1d"
onPeriodChange={onPeriodChange}
isLogin={true}
/>,
);
expect(container.querySelector(".opacity-60")).toBeInTheDocument();
expect(
screen.getByRole("switch", { name: "monitor.peakCut" }),
).toHaveAttribute("data-state", "checked");
await user.click(screen.getByText("monitor.period30d"));
expect(onPeriodChange).toHaveBeenCalledWith("30d");
await user.click(screen.getByRole("switch", { name: "monitor.peakCut" }));
expect(
screen.getByRole("switch", { name: "monitor.peakCut" }),
).toHaveAttribute("data-state", "unchecked");
});
});
+153
View File
@@ -0,0 +1,153 @@
import { fireEvent, screen } from "@testing-library/react";
import { useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ServerCard from "@/components/ServerCard";
import ServerCardInline from "@/components/ServerCardInline";
import { createServer } from "@/test/fixtures";
import { renderWithProviders } from "@/test/utils";
const publicNote = JSON.stringify({
billingDataMod: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
autoRenewal: "0",
cycle: "monthly",
amount: "10",
},
planDataMod: {
bandwidth: "1Gbps",
trafficVol: "2TB",
trafficType: "monthly",
IPv4: "1",
IPv6: "1",
networkRoute: "CN2,CMI",
extra: "Premium",
},
});
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("ServerCard", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
Object.assign(window, {
ForceUseSvgFlag: true,
FixedTopServerName: true,
ShowNetTransfer: true,
});
});
it("renders online server metrics, billing, plan data, and navigates on click", async () => {
const server = createServer({
id: 7,
name: "edge-online",
public_note: publicNote,
host: { platform: "Windows Server" },
});
renderWithProviders(
<>
<ServerCard
now={Date.parse("2025-01-01T00:00:20.000Z")}
serverInfo={server}
/>
<LocationProbe />
</>,
);
expect(screen.getByText("edge-online")).toBeInTheDocument();
expect(screen.getByText("Windows")).toBeInTheDocument();
expect(screen.getByText("12.00%")).toBeInTheDocument();
expect(screen.getAllByText("25.00%")).toHaveLength(2);
expect(screen.getByText("serverCard.upload:2.00 GiB")).toBeInTheDocument();
expect(
screen.getByText("serverCard.download:1.00 GiB"),
).toBeInTheDocument();
expect(screen.getByText("1Gbps")).toBeInTheDocument();
expect(
screen.getAllByText(/billingInfo.remaining: 16/).length,
).toBeGreaterThan(0);
Object.defineProperty(window, "scrollY", {
configurable: true,
value: 432,
});
fireEvent.click(screen.getByText("edge-online"));
expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(sessionStorage.getItem("scrollPosition")).toBe("432");
expect(screen.getByText("/server/7")).toBeInTheDocument();
});
it("renders a compact offline card without live metric blocks", () => {
const server = createServer({
id: 8,
name: "edge-offline",
public_note: publicNote,
last_active: "2024-12-31T23:00:00.000Z",
});
renderWithProviders(
<ServerCard
now={Date.parse("2025-01-01T00:00:20.000Z")}
serverInfo={server}
/>,
);
expect(screen.getByText("edge-offline")).toBeInTheDocument();
expect(screen.getByText("1Gbps")).toBeInTheDocument();
expect(screen.queryByText("CPU")).not.toBeInTheDocument();
});
});
describe("ServerCardInline", () => {
beforeEach(() => {
Object.assign(window, { ForceUseSvgFlag: true });
});
it("renders online inline server detail columns", () => {
const server = createServer({
id: 9,
name: "edge-inline",
public_note: publicNote,
state: { uptime: 2 * 86_400 },
});
renderWithProviders(
<ServerCardInline
now={Date.parse("2025-01-01T00:00:20.000Z")}
serverInfo={server}
/>,
);
expect(screen.getByText("edge-inline")).toBeInTheDocument();
expect(screen.getByText("serverCard.system")).toBeInTheDocument();
expect(screen.getByText("2 serverCard.days")).toBeInTheDocument();
expect(screen.getByText("2.00 GiB")).toBeInTheDocument();
expect(screen.getByText("1.00 GiB")).toBeInTheDocument();
});
it("renders offline inline server cards with saved plan data", () => {
const server = createServer({
id: 10,
name: "edge-inline-offline",
public_note: publicNote,
last_active: "2024-12-31T23:00:00.000Z",
});
renderWithProviders(
<ServerCardInline
now={Date.parse("2025-01-01T00:00:20.000Z")}
serverInfo={server}
/>,
);
expect(screen.getByText("edge-inline-offline")).toBeInTheDocument();
expect(screen.getByText("1Gbps")).toBeInTheDocument();
expect(screen.queryByText("serverCard.system")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,288 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ServerDetailChart from "@/components/ServerDetailChart";
import { createServer, createSettingResponse } from "@/test/fixtures";
import { createTestQueryClient } from "@/test/utils";
import type { NezhaServer, NezhaWebsocketResponse } from "@/types/nezha-api";
const detailChartMocks = vi.hoisted(() => ({
connected: true,
fetchLoginUser: vi.fn(),
fetchServerMetrics: vi.fn(),
fetchSetting: vi.fn(),
lastData: null as NezhaWebsocketResponse | null,
messageHistory: [] as NezhaWebsocketResponse[],
}));
vi.mock("recharts", () => {
const createElement =
(testId: string) =>
({
children,
data,
dataKey,
}: {
children?: ReactNode;
data?: unknown[];
dataKey?: string;
}) => (
<div data-key={dataKey} data-points={data?.length} data-testid={testId}>
{children}
</div>
);
const AreaChart = createElement("area-chart");
const LineChart = createElement("line-chart");
const genericChart = createElement("generic-chart");
return {
Area: createElement("area"),
AreaChart,
BarChart: genericChart,
CartesianGrid: createElement("grid"),
ComposedChart: genericChart,
FunnelChart: genericChart,
Legend: ({ content }: { content?: ReactNode }) => (
<div data-testid="chart-legend">{content}</div>
),
Line: createElement("line"),
LineChart,
PieChart: genericChart,
RadarChart: genericChart,
RadialBarChart: genericChart,
ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
<div data-testid="responsive-chart">{children}</div>
),
Sankey: genericChart,
ScatterChart: genericChart,
Tooltip: ({ content }: { content?: ReactNode }) => (
<div data-testid="chart-tooltip">{content}</div>
),
Treemap: genericChart,
XAxis: createElement("x-axis"),
YAxis: createElement("y-axis"),
};
});
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: detailChartMocks.connected,
lastData: detailChartMocks.lastData,
messageHistory: detailChartMocks.messageHistory,
}),
}));
vi.mock("@/lib/nezha-api", () => ({
fetchLoginUser: detailChartMocks.fetchLoginUser,
fetchServerMetrics: detailChartMocks.fetchServerMetrics,
fetchSetting: detailChartMocks.fetchSetting,
}));
function settingResponse(tsdbEnabled = true) {
return {
...createSettingResponse(),
data: {
...createSettingResponse().data,
tsdb_enabled: tsdbEnabled,
},
};
}
function loginResponse() {
return {
success: true,
data: {
id: 1,
username: "admin",
password: "",
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
},
};
}
function metricsResponse(metric: string) {
return {
success: true,
data: {
server_id: 7,
server_name: "edge-chart-detail",
metric,
data_points: [
{ ts: Date.parse("2025-01-01T00:00:00.000Z"), value: 10 },
{ ts: Date.parse("2025-01-01T01:00:00.000Z"), value: 20 },
{ ts: Date.parse("2025-01-01T02:00:00.000Z"), value: 30 },
],
},
};
}
function websocketPayload(server: NezhaServer, now: number) {
return {
now,
servers: [server],
};
}
function renderWithQuery(ui: React.ReactElement) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
}
function seedWebSocketData() {
const baseNow = Date.parse("2025-01-01T00:00:20.000Z");
const server = createServer({
id: 7,
name: "edge-chart-detail",
host: {
gpu: ["NVIDIA T4"],
},
state: {
cpu: 45,
disk_used: 180,
gpu: [33],
mem_used: 80,
net_in_speed: 4 * 1024 ** 2,
net_out_speed: 3 * 1024 ** 2,
process_count: 77,
swap_used: 25,
tcp_conn_count: 18,
udp_conn_count: 9,
},
});
detailChartMocks.connected = true;
detailChartMocks.lastData = websocketPayload(server, baseNow);
detailChartMocks.messageHistory = [0, 1, 2].map((index) =>
websocketPayload(
createServer({
id: 7,
host: {
gpu: ["NVIDIA T4"],
},
state: {
cpu: 30 + index,
disk_used: 100 + index * 10,
gpu: [20 + index],
mem_used: 50 + index * 5,
net_in_speed: (1 + index) * 1024 ** 2,
net_out_speed: (2 + index) * 1024 ** 2,
process_count: 60 + index,
swap_used: 10 + index,
tcp_conn_count: 10 + index,
udp_conn_count: 5 + index,
},
}),
baseNow - index * 1000,
),
);
}
describe("ServerDetailChart", () => {
beforeEach(() => {
detailChartMocks.connected = true;
detailChartMocks.fetchLoginUser.mockReset();
detailChartMocks.fetchServerMetrics.mockReset();
detailChartMocks.fetchSetting.mockReset();
detailChartMocks.lastData = null;
detailChartMocks.messageHistory = [];
detailChartMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
detailChartMocks.fetchSetting.mockResolvedValue(settingResponse());
});
it("renders the loading grid without websocket data", () => {
detailChartMocks.connected = false;
const { container } = renderWithQuery(<ServerDetailChart server_id="7" />);
expect(container.querySelectorAll(".h-\\[182px\\]")).toHaveLength(6);
});
it("renders realtime resource, network, connection, and GPU charts", async () => {
const user = userEvent.setup();
seedWebSocketData();
renderWithQuery(<ServerDetailChart server_id="7" />);
expect(
await screen.findByText("serverDetailChart.realtime"),
).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.period1d")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.period7d")).toBeInTheDocument();
expect(screen.getByText("CPU")).toBeInTheDocument();
expect(screen.getByText("GPU: NVIDIA T4")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.mem")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.swap")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.disk")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.process")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.upload")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.download")).toBeInTheDocument();
expect(screen.getByText("TCP")).toBeInTheDocument();
expect(screen.getByText("UDP")).toBeInTheDocument();
expect(screen.getAllByTestId("area-chart").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("line-chart").length).toBeGreaterThan(0);
await user.click(screen.getByText("serverDetailChart.period7d"));
expect(detailChartMocks.fetchServerMetrics).not.toHaveBeenCalled();
});
it("prevents historical periods when TSDB is disabled", async () => {
const user = userEvent.setup();
seedWebSocketData();
detailChartMocks.fetchSetting.mockResolvedValue(settingResponse(false));
renderWithQuery(<ServerDetailChart server_id="7" />);
await screen.findByText("serverDetailChart.realtime");
await user.click(screen.getByText("serverDetailChart.period1d"));
expect(detailChartMocks.fetchServerMetrics).not.toHaveBeenCalled();
});
it("fetches every historical metric group for the selected period", async () => {
const user = userEvent.setup();
seedWebSocketData();
Object.defineProperty(document, "cookie", {
configurable: true,
value: "session=1",
});
detailChartMocks.fetchLoginUser.mockResolvedValue(loginResponse());
detailChartMocks.fetchServerMetrics.mockImplementation(
(_serverId: number, metric: string) =>
Promise.resolve(metricsResponse(metric)),
);
renderWithQuery(<ServerDetailChart server_id="7" />);
await screen.findByText("serverDetailChart.realtime");
await user.click(screen.getByText("serverDetailChart.period1d"));
for (const metric of [
"cpu",
"gpu",
"memory",
"swap",
"disk",
"process_count",
"net_out_speed",
"net_in_speed",
"tcp_conn",
"udp_conn",
]) {
await waitFor(() => {
expect(detailChartMocks.fetchServerMetrics).toHaveBeenCalledWith(
7,
metric,
"1d",
);
});
}
});
});
@@ -0,0 +1,143 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ServerDetailOverview from "@/components/ServerDetailOverview";
import ServerDetailSummary from "@/components/ServerDetailSummary";
import { createServer } from "@/test/fixtures";
const websocketMocks = vi.hoisted(() => ({
connected: true,
lastData: null as {
now: number;
servers: ReturnType<typeof createServer>[];
} | null,
}));
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => websocketMocks,
}));
function seedWebSocketData({
server = createServer(),
now = Date.parse("2025-01-01T00:00:20.000Z"),
} = {}) {
websocketMocks.connected = true;
websocketMocks.lastData = {
now,
servers: [server],
};
}
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("ServerDetailSummary", () => {
beforeEach(() => {
websocketMocks.connected = true;
websocketMocks.lastData = null;
});
it("renders nothing until websocket data exists", () => {
websocketMocks.connected = false;
const { container } = render(<ServerDetailSummary server_id={1} />);
expect(container).toBeEmptyDOMElement();
});
it("renders resource, network, and connection summaries for the selected server", () => {
seedWebSocketData();
render(<ServerDetailSummary server_id={1} />);
expect(screen.getByText("12.00%")).toBeInTheDocument();
expect(screen.getAllByText("25.00%")).toHaveLength(2);
expect(screen.getByText("Process")).toBeInTheDocument();
expect(screen.getByText("88")).toBeInTheDocument();
expect(screen.getByText("TCP")).toBeInTheDocument();
expect(screen.getByText("8")).toBeInTheDocument();
expect(screen.getByText("UDP")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("1.00M/s")).toBeInTheDocument();
expect(screen.getByText("2.00M/s")).toBeInTheDocument();
});
});
describe("ServerDetailOverview", () => {
beforeEach(() => {
websocketMocks.connected = true;
websocketMocks.lastData = null;
Object.assign(window, { ForceUseSvgFlag: true });
});
it("shows the loading shell when websocket data or the selected server is missing", () => {
websocketMocks.connected = false;
const { rerender } = render(
<MemoryRouter>
<ServerDetailOverview server_id="1" />
</MemoryRouter>,
);
expect(screen.getAllByAltText("BackIcon").length).toBeGreaterThan(0);
seedWebSocketData();
rerender(
<MemoryRouter>
<ServerDetailOverview server_id="404" />
</MemoryRouter>,
);
expect(screen.getAllByAltText("BackIcon").length).toBeGreaterThan(0);
});
it("renders server identity, hardware, traffic, and temperature details", async () => {
const user = userEvent.setup();
sessionStorage.setItem("fromMainPage", "true");
seedWebSocketData({
server: createServer({
id: 7,
name: "edge-detail",
country_code: "us",
host: {
gpu: ["NVIDIA T4"],
},
state: {
temperatures: [{ Name: "CPU Core", Temperature: 55.5 }],
},
}),
});
const { unmount } = render(
<MemoryRouter initialEntries={["/", "/server/7"]} initialIndex={1}>
<ServerDetailOverview server_id="7" />
<LocationProbe />
</MemoryRouter>,
);
expect(screen.getByText("edge-detail")).toBeInTheDocument();
expect(screen.getByText("serverDetail.online")).toBeInTheDocument();
expect(screen.getByText("1.0.0")).toBeInTheDocument();
expect(screen.getByText("amd64")).toBeInTheDocument();
expect(screen.getByText("US")).toBeInTheDocument();
expect(screen.getByText(/linux - 6.8/)).toBeInTheDocument();
expect(screen.getByText(/AMD EPYC/)).toBeInTheDocument();
expect(screen.getByText("NVIDIA T4")).toBeInTheDocument();
expect(screen.getByText("2.00 GiB")).toBeInTheDocument();
expect(screen.getByText("1.00 GiB")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /serverDetail.temperature/ }),
);
expect(screen.getByText("CPU Core")).toBeInTheDocument();
expect(screen.getByText(/55.50 °C/)).toBeInTheDocument();
await user.click(screen.getByText("edge-detail"));
expect(screen.getByText("/")).toBeInTheDocument();
unmount();
expect(sessionStorage.getItem("fromMainPage")).toBeNull();
});
});
@@ -0,0 +1,274 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CycleTransferStatsCard } from "@/components/CycleTransferStats";
import CycleTransferStatsClient from "@/components/CycleTransferStatsClient";
import { ServiceTracker } from "@/components/ServiceTracker";
import ServiceTrackerClient from "@/components/ServiceTrackerClient";
import { createServer } from "@/test/fixtures";
import { createTestQueryClient } from "@/test/utils";
const apiMocks = vi.hoisted(() => ({
fetchService: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => apiMocks);
function renderWithQuery(ui: React.ReactElement) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
}
describe("ServiceTracker", () => {
it("shows the loading state while service data is pending", () => {
apiMocks.fetchService.mockReturnValue(new Promise(() => undefined));
renderWithQuery(<ServiceTracker serverList={[createServer()]} />);
expect(screen.getByText("serviceTracker.loading")).toBeInTheDocument();
});
it("shows an empty state when there are no services or cycle stats", async () => {
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {},
});
renderWithQuery(<ServiceTracker serverList={[createServer()]} />);
expect(
await screen.findByText("serviceTracker.noService"),
).toBeInTheDocument();
});
it("renders processed service uptime, delay, and matching cycle transfer stats", async () => {
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {
services: {
http: {
service_name: "HTTP Ping",
current_up: 0,
current_down: 0,
total_up: 5,
total_down: 3,
delay: [80, 120, 450],
up: [3, 2, 0],
down: [0, 1, 2],
},
},
cycle_transfer_stats: {
monthly: {
name: "Monthly",
from: "2025-01-01T00:00:00.000Z",
to: "2025-02-01T00:00:00.000Z",
max: 10 * 1024,
min: 0,
server_name: {
"1": "edge-1",
"2": "hidden-server",
},
transfer: {
"1": 5 * 1024,
"2": 1024,
},
next_update: {
"1": "2025-01-15T12:00:00.000Z",
"2": "2025-01-15T12:00:00.000Z",
},
},
},
},
});
renderWithQuery(<ServiceTracker serverList={[createServer({ id: 1 })]} />);
expect(await screen.findByText("HTTP Ping")).toBeInTheDocument();
expect(screen.getByText("217ms")).toBeInTheDocument();
expect(screen.getByText("62.5% serviceTracker.uptime")).toBeInTheDocument();
expect(screen.getByText("edge-1")).toBeInTheDocument();
expect(screen.getByText("Monthly")).toBeInTheDocument();
expect(screen.queryByText("hidden-server")).not.toBeInTheDocument();
});
it("falls back to zero uptime when a service has no checks", async () => {
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {
services: {
http: {
service_name: "HTTP Ping",
current_up: 0,
current_down: 0,
total_up: 0,
total_down: 0,
delay: [],
up: [0, 0],
down: [0, 0],
},
},
},
});
renderWithQuery(<ServiceTracker serverList={[createServer()]} />);
expect(await screen.findByText("HTTP Ping")).toBeInTheDocument();
expect(screen.getByText("0.0% serviceTracker.uptime")).toBeInTheDocument();
});
});
describe("CycleTransferStatsCard", () => {
it("renders nothing for empty server lists", () => {
const { container } = render(
<CycleTransferStatsCard serverList={[]} cycleStats={{}} />,
);
expect(container).toBeEmptyDOMElement();
});
it("supports per-server date and max maps while skipping incomplete entries", () => {
render(
<CycleTransferStatsCard
serverList={[createServer({ id: 7 })]}
cycleStats={{
mapped: {
name: "Mapped",
from: {
"7": "2025-01-01T00:00:00.000Z",
},
to: {
"7": "2025-01-31T00:00:00.000Z",
},
max: {
"7": 100,
},
min: {
"7": 0,
},
server_name: {
"7": "edge-7",
"8": "edge-8",
},
transfer: {
"7": 25,
"8": 25,
},
next_update: {
"7": "2025-01-15T12:00:00.000Z",
"8": "2025-01-15T12:00:00.000Z",
},
},
incomplete: {
name: "Incomplete",
from: "",
to: "",
max: 0,
min: 0,
server_name: {
"7": "missing-fields",
},
transfer: {
"7": 25,
},
next_update: {
"7": "2025-01-15T12:00:00.000Z",
},
},
}}
/>,
);
expect(screen.getByText("edge-7")).toBeInTheDocument();
expect(screen.getByText("Mapped")).toBeInTheDocument();
expect(screen.queryByText("edge-8")).not.toBeInTheDocument();
expect(screen.queryByText("missing-fields")).not.toBeInTheDocument();
});
});
describe("ServiceTrackerClient and CycleTransferStatsClient", () => {
it("uses uptime and delay severity colors for service summaries", () => {
const days = [
{
completed: true,
date: new Date("2025-01-01T00:00:00.000Z"),
uptime: 99.5,
delay: 80,
},
{
completed: false,
date: new Date("2025-01-02T00:00:00.000Z"),
uptime: 90,
delay: 320,
},
];
const { rerender } = render(
<ServiceTrackerClient
title="API"
uptime={99.9}
avgDelay={80}
days={days}
/>,
);
expect(screen.getByText("99.9% serviceTracker.uptime")).toHaveClass(
"text-emerald-500",
);
expect(screen.getByText("80ms")).toHaveClass("text-emerald-500");
rerender(
<ServiceTrackerClient
title="API"
uptime={97}
avgDelay={180}
days={days}
/>,
);
expect(screen.getByText("97.0% serviceTracker.uptime")).toHaveClass(
"text-amber-500",
);
expect(screen.getByText("180ms")).toHaveClass("text-amber-500");
rerender(
<ServiceTrackerClient
title="API"
uptime={94}
avgDelay={320}
days={days}
/>,
);
expect(screen.getByText("94.0% serviceTracker.uptime")).toHaveClass(
"text-rose-500",
);
expect(screen.getByText("320ms")).toHaveClass("text-rose-500");
});
it("caps cycle transfer progress at one hundred percent", () => {
const { container } = render(
<CycleTransferStatsClient
name="Monthly"
from="2025-01-01T00:00:00.000Z"
to="2025-01-31T00:00:00.000Z"
max={1024}
serverStats={[
{
serverId: "1",
serverName: "edge-1",
transfer: 2048,
nextUpdate: "2025-01-15T12:00:00.000Z",
},
]}
/>,
);
expect(screen.getByText("200.0%")).toBeInTheDocument();
expect(
container.querySelector('[style="width: 100%;"]'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,291 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeProvider } from "@/components/ThemeProvider";
import { ModeToggle } from "@/components/ThemeSwitcher";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
describe("language and theme switchers", () => {
it("opens the language menu and marks the active locale", async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
await user.click(screen.getByRole("button", { name: "Change language" }));
expect(screen.getByText("language.zh-CN")).toBeInTheDocument();
expect(screen.getByText("language.en-US")).toHaveClass("font-semibold");
});
it("updates the theme through the mode menu", async () => {
const user = userEvent.setup();
render(
<ThemeProvider storageKey="theme-switcher-test">
<ModeToggle />
</ThemeProvider>,
);
await user.click(screen.getByRole("button", { name: "Toggle theme" }));
await user.click(await screen.findByText("theme.dark"));
expect(document.documentElement).toHaveClass("dark");
expect(localStorage.getItem("theme-switcher-test")).toBe("dark");
});
});
describe("Radix UI wrappers", () => {
it("opens accordion, popover, tooltip, and dialog content", async () => {
const user = userEvent.setup();
render(
<>
<Accordion type="single" collapsible>
<AccordionItem value="status">
<AccordionTrigger>Toggle status</AccordionTrigger>
<AccordionContent>Status body</AccordionContent>
</AccordionItem>
</Accordion>
<Popover>
<PopoverTrigger>Open popover</PopoverTrigger>
<PopoverContent>Popover body</PopoverContent>
</Popover>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>Hover status</TooltipTrigger>
<TooltipContent>Tooltip body</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog>
<DialogTrigger>Open dialog</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm action</DialogTitle>
<DialogDescription>Dialog body</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose>Done</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>,
);
await user.click(screen.getByRole("button", { name: "Toggle status" }));
expect(screen.getByText("Status body")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open popover" }));
expect(screen.getByText("Popover body")).toBeInTheDocument();
await user.hover(screen.getByText("Hover status"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Tooltip body",
);
await user.click(screen.getByRole("button", { name: "Open dialog" }));
expect(screen.getByText("Confirm action")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Done" }));
await waitFor(() => {
expect(screen.queryByText("Confirm action")).not.toBeInTheDocument();
});
});
it("toggles checkbox and switch controls through labels", async () => {
const user = userEvent.setup();
const onCheckboxChange = vi.fn();
const onSwitchChange = vi.fn();
render(
<>
<Label htmlFor="alerts">Alerts</Label>
<Checkbox id="alerts" onCheckedChange={onCheckboxChange} />
<Switch
id="compact"
aria-label="Compact mode"
onCheckedChange={onSwitchChange}
/>
</>,
);
await user.click(screen.getByText("Alerts"));
await user.click(screen.getByRole("switch", { name: "Compact mode" }));
expect(onCheckboxChange).toHaveBeenCalledWith(true);
expect(onSwitchChange).toHaveBeenCalledWith(true);
expect(screen.getByRole("checkbox")).toHaveAttribute(
"data-state",
"checked",
);
expect(
screen.getByRole("switch", { name: "Compact mode" }),
).toHaveAttribute("data-state", "checked");
});
it("renders dropdown labels, indicators, shortcuts, and submenus", async () => {
render(
<DropdownMenu open={true}>
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel inset={true}>Actions</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem inset={true}>
Copy
<DropdownMenuShortcut>Ctrl+C</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuCheckboxItem checked={true}>
Show hidden
</DropdownMenuCheckboxItem>
<DropdownMenuRadioGroup value="compact">
<DropdownMenuRadioItem value="compact">
Compact rows
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuSub open={true}>
<DropdownMenuSubTrigger inset={true}>
More actions
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Archive</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(await screen.findByText("Actions")).toHaveClass("pl-8");
expect(screen.getByText("Copy")).toHaveClass("pl-8");
expect(screen.getByText("Ctrl+C")).toHaveClass("tracking-widest");
expect(screen.getByText("Show hidden")).toHaveAttribute(
"data-state",
"checked",
);
expect(screen.getByText("Compact rows")).toHaveAttribute(
"data-state",
"checked",
);
expect(screen.getByText("More actions")).toHaveClass("pl-8");
expect(screen.getByText("Archive")).toBeInTheDocument();
});
it("selects values and renders table structure", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<>
<Select onValueChange={onValueChange}>
<SelectTrigger aria-label="Region">
<SelectValue placeholder="Choose region" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Regions</SelectLabel>
<SelectItem value="asia">Asia</SelectItem>
<SelectSeparator />
<SelectItem value="europe">Europe</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Table>
<TableCaption>Server summary</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>edge-1</TableCell>
<TableCell>online</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell>1</TableCell>
</TableRow>
</TableFooter>
</Table>
</>,
);
await user.click(screen.getByRole("combobox", { name: "Region" }));
await user.click(await screen.findByRole("option", { name: "Asia" }));
expect(onValueChange).toHaveBeenCalledWith("asia");
expect(screen.getByText("Server summary")).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Name" }),
).toBeInTheDocument();
expect(screen.getByText("edge-1")).toBeInTheDocument();
});
});
+359
View File
@@ -0,0 +1,359 @@
import { act, render, renderHook, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CommandProvider } from "@/context/command-provider";
import { SortProvider } from "@/context/sort-provider";
import { StatusProvider } from "@/context/status-provider";
import { TooltipProvider } from "@/context/tooltip-provider";
import { WebSocketProvider } from "@/context/websocket-provider";
import { useCommand } from "@/hooks/use-command";
import { useSort } from "@/hooks/use-sort";
import { useStatus } from "@/hooks/use-status";
import { useTooltip } from "@/hooks/use-tooltip";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { createServer } from "@/test/fixtures";
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: FakeWebSocket[] = [];
readonly url: string;
readyState = FakeWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onmessage: ((event: MessageEvent<string>) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.(new Event("open"));
}
message(data: string) {
this.onmessage?.(new MessageEvent("message", { data }));
}
close() {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.(new CloseEvent("close"));
}
}
function CommandProbe() {
const { closeCommand, isOpen, openCommand, toggleCommand } = useCommand();
return (
<div>
<p data-testid="command-state">{isOpen ? "open" : "closed"}</p>
<button type="button" onClick={openCommand}>
open
</button>
<button type="button" onClick={closeCommand}>
close
</button>
<button type="button" onClick={toggleCommand}>
toggle
</button>
</div>
);
}
function SortProbe() {
const { setSortOrder, setSortType, sortOrder, sortType } = useSort();
return (
<div>
<p>{`${sortType}:${sortOrder}`}</p>
<button type="button" onClick={() => setSortType("cpu")}>
cpu
</button>
<button type="button" onClick={() => setSortOrder("asc")}>
asc
</button>
</div>
);
}
function StatusProbe() {
const { setStatus, status } = useStatus();
return (
<div>
<p data-testid="status-state">{status}</p>
<button type="button" onClick={() => setStatus("online")}>
online
</button>
</div>
);
}
function TooltipProbe() {
const { setTooltipData, tooltipData } = useTooltip();
return (
<div>
<p>{tooltipData?.country ?? "empty"}</p>
<button
type="button"
onClick={() =>
setTooltipData({
centroid: [10, 20],
country: "China",
count: 1,
servers: [{ id: 1, name: "edge", status: true }],
})
}
>
show
</button>
</div>
);
}
function WebSocketProbe() {
const {
connected,
lastData,
messageHistory,
needReconnect,
setNeedReconnect,
} = useWebSocketContext();
return (
<div>
<p>{connected ? "connected" : "disconnected"}</p>
<p>{lastData?.servers[0]?.name ?? "none"}</p>
<p data-testid="message-count">{messageHistory.length}</p>
<p data-testid="history-server-count">
{messageHistory.reduce((total, item) => total + item.servers.length, 0)}
</p>
<p>{needReconnect ? "needs-reconnect" : "stable"}</p>
<button type="button" onClick={() => setNeedReconnect(true)}>
mark
</button>
</div>
);
}
describe("state providers", () => {
it("manages command palette open state", async () => {
const user = userEvent.setup();
render(
<CommandProvider>
<CommandProbe />
</CommandProvider>,
);
expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
await user.click(screen.getByRole("button", { name: "open" }));
expect(screen.getByTestId("command-state")).toHaveTextContent("open");
await user.click(screen.getByRole("button", { name: "toggle" }));
expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
await user.click(screen.getByRole("button", { name: "open" }));
await user.click(screen.getByRole("button", { name: "close" }));
expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
});
it("uses forced sort globals when valid and still allows local updates", async () => {
window.ForceSortType = "mem";
window.ForceSortOrder = "asc";
const user = userEvent.setup();
render(
<SortProvider>
<SortProbe />
</SortProvider>,
);
expect(screen.getByText("mem:asc")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "cpu" }));
expect(screen.getByText("cpu:asc")).toBeInTheDocument();
});
it("falls back to default sort values for invalid forced globals", () => {
window.ForceSortType = "invalid";
window.ForceSortOrder = "up";
render(
<SortProvider>
<SortProbe />
</SortProvider>,
);
expect(screen.getByText("default:desc")).toBeInTheDocument();
});
it("manages server status filters", async () => {
const user = userEvent.setup();
render(
<StatusProvider>
<StatusProbe />
</StatusProvider>,
);
expect(screen.getByTestId("status-state")).toHaveTextContent("all");
await user.click(screen.getByRole("button", { name: "online" }));
expect(screen.getByTestId("status-state")).toHaveTextContent("online");
});
it("stores map tooltip data", async () => {
const user = userEvent.setup();
render(
<TooltipProvider>
<TooltipProbe />
</TooltipProvider>,
);
expect(screen.getByText("empty")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "show" }));
expect(screen.getByText("China")).toBeInTheDocument();
});
});
describe("context hooks", () => {
it("throws helpful errors when strict hooks miss their providers", () => {
expect(() => renderHook(() => useCommand())).toThrow(
"useCommand must be used within a CommandProvider",
);
expect(() => renderHook(() => useSort())).toThrow(
"useStatus must be used within a SortProvider",
);
expect(() => renderHook(() => useStatus())).toThrow(
"useStatus must be used within a StatusProvider",
);
expect(() => renderHook(() => useTooltip())).toThrow(
"useTooltip must be used within a TooltipProvider",
);
});
});
describe("WebSocketProvider", () => {
beforeEach(() => {
FakeWebSocket.instances = [];
vi.stubGlobal("WebSocket", FakeWebSocket);
});
function renderWebSocketProvider(children: ReactNode) {
return render(
<WebSocketProvider url="/api/v1/ws/server">{children}</WebSocketProvider>,
);
}
function websocketPayload(serverName: string) {
return JSON.stringify({
now: Date.parse("2025-01-01T00:00:20.000Z"),
servers: [createServer({ name: serverName })],
});
}
it("connects with a ws URL and records incoming messages", () => {
renderWebSocketProvider(<WebSocketProbe />);
const socket = FakeWebSocket.instances[0];
expect(socket.url).toBe("wss://localhost/api/v1/ws/server");
act(() => {
socket.open();
});
expect(screen.getByText("connected")).toBeInTheDocument();
act(() => {
socket.message(websocketPayload("first"));
socket.message(websocketPayload("second"));
});
expect(screen.getByText("second")).toBeInTheDocument();
expect(screen.getByTestId("message-count")).toHaveTextContent("2");
});
it("normalizes missing and non-array server collections", () => {
renderWebSocketProvider(<WebSocketProbe />);
const socket = FakeWebSocket.instances[0];
const now = Date.parse("2025-01-01T00:00:20.000Z");
act(() => {
socket.open();
socket.message(JSON.stringify({ now }));
socket.message(JSON.stringify({ now, servers: null }));
socket.message(JSON.stringify({ now, servers: { invalid: true } }));
});
expect(screen.getByText("none")).toBeInTheDocument();
expect(screen.getByTestId("message-count")).toHaveTextContent("3");
expect(screen.getByTestId("history-server-count")).toHaveTextContent("0");
});
it("keeps only the latest thirty websocket messages", () => {
renderWebSocketProvider(<WebSocketProbe />);
const socket = FakeWebSocket.instances[0];
act(() => {
socket.open();
for (let index = 0; index < 31; index += 1) {
socket.message(websocketPayload(`message-${index}`));
}
});
expect(screen.getByText("message-30")).toBeInTheDocument();
expect(screen.getByTestId("message-count")).toHaveTextContent("30");
});
it("ignores malformed websocket messages without disconnecting", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
renderWebSocketProvider(<WebSocketProbe />);
const socket = FakeWebSocket.instances[0];
act(() => {
socket.open();
socket.message("not-json");
});
expect(screen.getByText("connected")).toBeInTheDocument();
expect(screen.getByText("none")).toBeInTheDocument();
expect(screen.getByTestId("message-count")).toHaveTextContent("0");
expect(consoleError).toHaveBeenCalledWith(
"Failed to parse WebSocket message:",
expect.any(SyntaxError),
);
});
it("ignores websocket messages without a valid response shape", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
renderWebSocketProvider(<WebSocketProbe />);
const socket = FakeWebSocket.instances[0];
act(() => {
socket.open();
socket.message("null");
socket.message(JSON.stringify({ servers: [] }));
});
expect(screen.getByTestId("message-count")).toHaveTextContent("0");
expect(consoleError).toHaveBeenCalledTimes(2);
expect(consoleError).toHaveBeenCalledWith(
"Failed to parse WebSocket message:",
expect.any(TypeError),
);
});
it("exposes manual reconnect state separately from socket state", async () => {
const user = userEvent.setup();
renderWebSocketProvider(<WebSocketProbe />);
expect(screen.getByText("stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "mark" }));
expect(screen.getByText("needs-reconnect")).toBeInTheDocument();
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { NezhaServer } from "@/types/nezha-api";
type NezhaServerOverrides = Omit<Partial<NezhaServer>, "host" | "state"> & {
host?: Partial<NezhaServer["host"]>;
state?: Partial<NezhaServer["state"]>;
};
const baseHost: NezhaServer["host"] = {
platform: "linux",
platform_version: "6.8",
cpu: ["AMD EPYC"],
gpu: [],
mem_total: 200,
disk_total: 400,
swap_total: 100,
arch: "amd64",
boot_time: 1_735_603_200,
version: "1.0.0",
};
const baseState: NezhaServer["state"] = {
cpu: 12,
mem_used: 50,
swap_used: 10,
disk_used: 100,
net_in_transfer: 1024 ** 3,
net_out_transfer: 2 * 1024 ** 3,
net_in_speed: 1024 ** 2,
net_out_speed: 2 * 1024 ** 2,
uptime: 86_400,
load_1: 0.12,
load_5: 0.45,
load_15: 0.78,
tcp_conn_count: 8,
udp_conn_count: 4,
process_count: 88,
temperatures: [],
gpu: [],
};
export function createServer(
overrides: NezhaServerOverrides = {},
): NezhaServer {
return {
id: 1,
name: "edge-1",
public_note: "",
last_active: "2025-01-01T00:00:00.000Z",
country_code: "us",
...overrides,
host: {
...baseHost,
...overrides.host,
},
state: {
...baseState,
...overrides.state,
},
};
}
export function createSettingResponse() {
return {
success: true,
data: {
config: {
debug: false,
language: "en-US",
site_name: "Nezha",
user_template: "",
admin_template: "",
custom_code: "",
},
version: "1.0.0",
},
};
}
+129
View File
@@ -0,0 +1,129 @@
import { act, render, renderHook, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { useBackground } from "@/hooks/use-background";
import { useChartHistory } from "@/hooks/use-chart-history";
function BackgroundProbe() {
const { backgroundImage, updateBackground } = useBackground();
return (
<div>
<p>{backgroundImage ?? "empty"}</p>
<button type="button" onClick={() => updateBackground("/bg.png")}>
set
</button>
<button type="button" onClick={() => updateBackground(undefined)}>
clear
</button>
</div>
);
}
function ActiveIndicatorProbe({
active,
items,
}: {
active: string;
items: string[];
}) {
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(items, active);
return (
<div>
<div ref={containerRef}>
{items.map((item, index) => (
<div
key={item}
ref={setItemRef(index)}
data-testid={`item-${item}`}
onClick={enableIndicatorAnimation}
>
{item}
</div>
))}
</div>
<p>
{indicator ? `${indicator.width}:${indicator.shouldAnimate}` : "none"}
</p>
</div>
);
}
describe("useBackground", () => {
it("updates the global background image and broadcasts changes", async () => {
const user = userEvent.setup();
render(<BackgroundProbe />);
expect(screen.getByText("empty")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "set" }));
expect(window.CustomBackgroundImage).toBe("/bg.png");
expect(screen.getByText("/bg.png")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "clear" }));
expect(screen.getByText("empty")).toBeInTheDocument();
});
it("restores a saved background image during polling", async () => {
vi.useFakeTimers();
sessionStorage.setItem("savedBackgroundImage", "/saved.png");
render(<BackgroundProbe />);
act(() => {
vi.advanceTimersByTime(100);
});
expect(screen.getByText("/saved.png")).toBeInTheDocument();
});
});
describe("useChartHistory", () => {
it("formats websocket message history once and keeps newest data last", () => {
const history = [
{ now: 2, servers: [] },
{ now: 1, servers: [] },
];
const formatFn = vi.fn((wsData: { now: number }, serverId: number) =>
serverId === 1 ? wsData.now : null,
);
const { result, rerender } = renderHook(
({ messages }) => useChartHistory(messages, 1, formatFn),
{ initialProps: { messages: history } },
);
expect(result.current).toEqual([1, 2]);
expect(formatFn).toHaveBeenCalledTimes(2);
rerender({ messages: [...history, { now: 3, servers: [] }] });
expect(result.current).toEqual([1, 2]);
});
});
describe("useActiveIndicator", () => {
it("tracks the active item and clears when it is not available", async () => {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get() {
return 20;
},
});
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get() {
return 10;
},
});
const { rerender } = render(
<ActiveIndicatorProbe active="One" items={["One", "Two"]} />,
);
expect(screen.getByText("20:false")).toBeInTheDocument();
rerender(<ActiveIndicatorProbe active="Missing" items={["One", "Two"]} />);
expect(screen.getByText("none")).toBeInTheDocument();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { formatBytes } from "@/lib/format";
describe("formatBytes", () => {
it("formats empty byte values as zero KiB", () => {
expect(formatBytes(0)).toBe("0 KiB");
expect(formatBytes(Number.NaN)).toBe("0 KiB");
});
it("keeps byte values in binary units", () => {
expect(formatBytes(512)).toBe("0.50 KiB");
expect(formatBytes(1024)).toBe("1.00 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.00 MiB");
expect(formatBytes(1024 ** 3 * 2.5, 1)).toBe("2.5 GiB");
});
it("clamps negative decimal precision to an integer", () => {
expect(formatBytes(1536, -1)).toBe("2 KiB");
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { InjectContext } from "@/lib/inject";
describe("InjectContext", () => {
it("injects supported resource nodes and marks them for cleanup", async () => {
vi.spyOn(console, "log").mockImplementation(() => undefined);
await InjectContext(`
<meta name="x-test" content="enabled" />
<style>.custom { color: red; }</style>
<script>window.__injected = true;</script>
<div id="custom-node">custom content</div>
plain text
`);
expect(document.querySelector('meta[name="x-test"]')).toHaveAttribute(
"data-injected",
"true",
);
expect(document.querySelector("style[data-injected]")).toHaveTextContent(
".custom",
);
expect(document.querySelector("script[data-injected]")).toHaveTextContent(
"window.__injected = true;",
);
expect(document.querySelector("#custom-node")).toHaveAttribute(
"data-injected",
"true",
);
expect(document.body).toHaveTextContent("plain text");
});
it("cleans previous injected resources before applying new content", async () => {
vi.spyOn(console, "log").mockImplementation(() => undefined);
await InjectContext(`<div id="first">first</div>`);
await InjectContext(`<div id="second">second</div>`);
expect(document.querySelector("#first")).not.toBeInTheDocument();
expect(document.querySelector("#second")).toBeInTheDocument();
});
it("logs external resource failures without throwing to callers", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const appendChild = vi
.spyOn(document.head, "appendChild")
.mockImplementation((node) => {
if (node instanceof HTMLScriptElement) {
setTimeout(() => node.onerror?.(new Event("error")), 0);
}
return node;
});
await InjectContext(`<script src="/missing.js"></script>`);
expect(appendChild).toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
"Error during resource injection:",
expect.any(Error),
);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
GetFontLogoClass,
GetOsName,
MageMicrosoftWindows,
} from "@/lib/logo-class";
describe("logo-class helpers", () => {
it("maps known platform aliases to font logo classes", () => {
expect(GetFontLogoClass("ubuntu")).toBe("ubuntu");
expect(GetFontLogoClass("darwin")).toBe("apple");
expect(GetFontLogoClass("linux")).toBe("tux");
expect(GetFontLogoClass("amazon")).toBe("redhat");
expect(GetFontLogoClass("arch")).toBe("archlinux");
expect(GetFontLogoClass("opensuse-tumbleweed")).toBe("opensuse");
expect(GetFontLogoClass("unknown")).toBe("tux");
});
it("maps platform aliases to readable operating system names", () => {
expect(GetOsName("ubuntu")).toBe("Ubuntu");
expect(GetOsName("darwin")).toBe("macOS");
expect(GetOsName("linux")).toBe("Linux");
expect(GetOsName("amazon")).toBe("Redhat");
expect(GetOsName("arch")).toBe("Archlinux");
expect(GetOsName("opensuse-tumbleweed")).toBe("Opensuse");
expect(GetOsName("unknown")).toBe("Linux");
});
it("renders the Windows SVG icon", () => {
render(<MageMicrosoftWindows data-testid="windows-icon" />);
expect(screen.getByTestId("windows-icon")).toHaveAttribute(
"viewBox",
"0 0 24 24",
);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchLoginUser,
fetchMonitor,
fetchServerMetrics,
fetchService,
fetchSetting,
} from "@/lib/nezha-api";
const jsonResponse = (body: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(body), {
status: init?.status ?? 200,
statusText: init?.statusText,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
});
describe("nezha api fetchers", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("returns setting payloads", async () => {
const payload = {
success: true,
data: {
config: {
debug: false,
language: "en-US",
site_name: "Nezha",
user_template: "",
admin_template: "",
custom_code: "",
},
version: "1.0.0",
},
};
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(payload));
await expect(fetchSetting()).resolves.toEqual(payload);
expect(fetch).toHaveBeenCalledWith("/api/v1/setting");
});
it("throws API error messages returned by service endpoints", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse({ error: "service unavailable" }),
);
await expect(fetchService()).rejects.toThrow("service unavailable");
});
it("adds monitor and metrics query parameters when periods are provided", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse({ success: true, data: [] }))
.mockResolvedValueOnce(
jsonResponse({
success: true,
data: {
server_id: 7,
server_name: "edge",
metric: "cpu",
data_points: [],
},
}),
);
await fetchMonitor(7, "7d");
await fetchServerMetrics(7, "cpu", "30d");
expect(fetch).toHaveBeenNthCalledWith(
1,
"/api/v1/server/7/service?period=7d",
);
expect(fetch).toHaveBeenNthCalledWith(
2,
"/api/v1/server/7/metrics?metric=cpu&period=30d",
);
});
it("refreshes the token when a logged-in browser session has cookies", async () => {
const consoleLog = vi
.spyOn(console, "log")
.mockImplementation(() => undefined);
Object.defineProperty(document, "cookie", {
configurable: true,
value: "nezha_token=token; nz-csrf=test-csrf-token",
});
const payload = {
success: true,
data: {
id: 1,
username: "admin",
password: "",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
};
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(payload))
.mockResolvedValueOnce(jsonResponse({ success: true }));
await expect(fetchLoginUser()).resolves.toEqual(payload);
expect(fetch).toHaveBeenNthCalledWith(1, "/api/v1/profile");
expect(fetch).toHaveBeenNthCalledWith(2, "/api/v1/refresh-token", {
method: "POST",
headers: {
"X-CSRF-Token": "test-csrf-token",
},
});
expect(consoleLog).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { geoJsonString } from "@/lib/geo-json-string";
import { countryCoordinates } from "@/lib/geo-limit";
describe("static geographic data", () => {
it("provides known country coordinate metadata", () => {
expect(countryCoordinates.CN).toMatchObject({
lat: 35,
lng: 105,
name: "China",
});
expect(countryCoordinates.US.name).toBe("United States");
});
it("ships parseable GeoJSON feature collection data", () => {
const geoJson = JSON.parse(geoJsonString) as {
type: string;
features: unknown[];
};
expect(geoJson.type).toBe("FeatureCollection");
expect(geoJson.features.length).toBeGreaterThan(0);
});
});
+346
View File
@@ -0,0 +1,346 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetcher,
formatNezhaInfo,
formatRelativeTime,
formatTime,
getDaysBetweenDates,
getDaysBetweenDatesWithAutoRenewal,
getNextCycleTime,
handlePublicNote,
nezhaFetcher,
parsePublicNote,
} from "@/lib/utils";
import type { NezhaServer } from "@/types/nezha-api";
const serverFixture: NezhaServer = {
id: 1,
name: "edge",
public_note: '{"planDataMod":{"bandwidth":"1Gbps"}}',
last_active: "2025-01-01T00:00:00.000Z",
country_code: "US",
host: {
platform: "linux",
platform_version: "6.8",
cpu: ["AMD EPYC"],
gpu: [],
mem_total: 200,
disk_total: 400,
swap_total: 100,
arch: "amd64",
boot_time: 1_735_603_200,
version: "1.0.0",
},
state: {
cpu: 12,
mem_used: 50,
swap_used: 10,
disk_used: 100,
net_in_transfer: 1024,
net_out_transfer: 2048,
net_in_speed: 1024 * 1024,
net_out_speed: 2 * 1024 * 1024,
uptime: 3600,
load_1: 0.123,
load_5: 0.456,
load_15: 0.789,
tcp_conn_count: 8,
udp_conn_count: 4,
process_count: 88,
temperatures: [],
gpu: [],
},
};
describe("date and billing helpers", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
});
it("calculates the next renewal cycle after the specified date", () => {
const nextCycle = getNextCycleTime(
Date.UTC(2025, 0, 1),
1,
Date.UTC(2025, 2, 15),
);
expect(new Date(nextCycle).toISOString()).toBe("2025-04-01T00:00:00.000Z");
});
it("normalizes common cycle labels and remaining package days", () => {
const result = getDaysBetweenDatesWithAutoRenewal({
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
autoRenewal: "0",
cycle: "monthly",
amount: "10",
});
expect(result.days).toBe(16);
expect(result.cycleLabel).toBe("月");
expect(result.remainingPercentage).toBeCloseTo(16 / 30);
});
it("handles auto-renewal before and after the current cycle end", () => {
expect(
getDaysBetweenDatesWithAutoRenewal({
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-02-01T00:00:00.000Z",
autoRenewal: "1",
cycle: "quarterly",
amount: "10",
}),
).toMatchObject({
days: 17,
cycleLabel: "季",
});
const renewed = getDaysBetweenDatesWithAutoRenewal({
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-12-01T00:00:00.000Z",
autoRenewal: "1",
cycle: "annual",
amount: "10",
});
expect(renewed.days).toBeGreaterThan(300);
expect(renewed.cycleLabel).toBe("年");
expect(renewed.remainingPercentage).toBeLessThanOrEqual(1);
});
it("rejects invalid cycle calculations", () => {
expect(() => getNextCycleTime(Date.UTC(2025, 0, 1), 0, Date.now())).toThrow(
"参数无效",
);
});
it("formats absolute dates and day differences", () => {
expect(formatTime(new Date(2025, 0, 2, 3, 4, 5).getTime())).toBe(
"2025-1-2 03:04:05",
);
expect(getDaysBetweenDates("2025-01-20", "2025-01-15")).toBe(5);
});
it("formats relative timestamps using compact units", () => {
expect(formatRelativeTime(Date.now() - 45 * 1000)).toBe("45s");
expect(formatRelativeTime(Date.now() - 5 * 60 * 1000)).toBe("5m");
expect(formatRelativeTime(Date.now() - 2 * 60 * 60 * 1000)).toBe("2h");
expect(formatRelativeTime(Date.now() - 3 * 24 * 60 * 60 * 1000)).toBe("3d");
});
});
describe("public note helpers", () => {
it("parses supported public note blocks and defaults missing strings", () => {
expect(
parsePublicNote(
JSON.stringify({
billingDataMod: {
endDate: "2025-12-31",
cycle: "year",
},
planDataMod: {
bandwidth: "1Gbps",
IPv4: "1",
},
}),
),
).toEqual({
billingDataMod: {
startDate: "",
endDate: "2025-12-31",
autoRenewal: "",
cycle: "year",
amount: "",
},
planDataMod: {
bandwidth: "1Gbps",
trafficVol: "",
trafficType: "",
IPv4: "1",
IPv6: "",
networkRoute: "",
extra: "",
},
});
});
it("returns null for invalid public note JSON", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(parsePublicNote("{bad json")).toBeNull();
});
it("returns null or partial blocks for empty and single-section public notes", () => {
expect(parsePublicNote("")).toBeNull();
expect(parsePublicNote(JSON.stringify({ note: "plain" }))).toBeNull();
expect(
parsePublicNote(
JSON.stringify({
billingDataMod: {
endDate: "2025-12-31",
amount: "20",
},
}),
),
).toEqual({
billingDataMod: {
startDate: "",
endDate: "2025-12-31",
autoRenewal: "",
cycle: "",
amount: "20",
},
});
expect(
parsePublicNote(
JSON.stringify({
planDataMod: {
trafficVol: "1TB",
extra: "Backup",
},
}),
),
).toEqual({
planDataMod: {
bandwidth: "",
trafficVol: "1TB",
trafficType: "",
IPv4: "",
IPv6: "",
networkRoute: "",
extra: "Backup",
},
});
});
it("falls back to cached notes when websocket payloads are empty", () => {
expect(handlePublicNote(1, "live note")).toBe("live note");
expect(handlePublicNote(1, "")).toBe("live note");
expect(sessionStorage.getItem("server_1_public_note")).toBe("live note");
});
it("returns an empty public note when no live or cached note exists", () => {
expect(handlePublicNote(404, "")).toBe("");
});
});
describe("nezha data formatting", () => {
it("maps websocket server state into view-friendly metrics", () => {
const now = new Date("2025-01-01T00:00:20.000Z").getTime();
const result = formatNezhaInfo(now, serverFixture);
expect(result.online).toBe(true);
expect(result.up).toBe(2);
expect(result.down).toBe(1);
expect(result.mem).toBe(25);
expect(result.swap).toBe(10);
expect(result.disk).toBe(25);
expect(result.load_1).toBe("0.12");
expect(result.public_note).toBe(serverFixture.public_note);
});
it("falls back safely for offline timestamps and empty host totals", () => {
const result = formatNezhaInfo(Date.now(), {
...serverFixture,
public_note: "",
last_active: "0001-01-01T00:00:00.000Z",
host: {
...serverFixture.host,
boot_time: 0,
mem_total: 0,
swap_total: 0,
disk_total: 0,
version: "",
cpu: [],
},
state: {
...serverFixture.state,
cpu: 0,
mem_used: 0,
swap_used: 0,
disk_used: 0,
net_in_speed: 0,
net_out_speed: 0,
process_count: 0,
},
});
expect(result.online).toBe(false);
expect(result.last_active_time_string).toBe("");
expect(result.boot_time_string).toBe("");
expect(result.version).toBeNull();
expect(result.mem).toBe(0);
expect(result.swap).toBe(0);
expect(result.disk).toBe(0);
expect(result.public_note).toBe("");
});
});
describe("fetcher", () => {
it("returns nested data for successful responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { ok: true } }), {
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(fetcher("/api/v1/demo")).resolves.toEqual({ ok: true });
});
it("logs and rethrows failed responses", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "bad" }), {
status: 500,
statusText: "Server Error",
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(fetcher("/api/v1/demo")).rejects.toThrow("Server Error");
expect(console.error).toHaveBeenCalled();
});
});
describe("nezhaFetcher", () => {
it("returns JSON for successful responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(nezhaFetcher("/api/v1/setting")).resolves.toEqual({
success: true,
});
});
it("throws status and response info for failed responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "forbidden" }), {
status: 403,
statusText: "Forbidden",
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(nezhaFetcher("/api/v1/setting")).rejects.toMatchObject({
status: 403,
info: { error: "forbidden" },
});
});
});
+545
View File
@@ -0,0 +1,545 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SortProvider } from "@/context/sort-provider";
import { StatusProvider } from "@/context/status-provider";
import type { WebSocketContextType } from "@/context/websocket-context";
import { WebSocketContext } from "@/context/websocket-context";
import { useStatus } from "@/hooks/use-status";
import Servers from "@/pages/Server";
import { createServer } from "@/test/fixtures";
import { renderWithProviders } from "@/test/utils";
import type { NezhaServer } from "@/types/nezha-api";
const apiMocks = vi.hoisted(() => ({
fetchServerGroup: vi.fn(),
fetchService: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => apiMocks);
vi.mock("@/components/GlobalMap", () => ({
default: ({ serverList }: { serverList: NezhaServer[] }) => (
<div data-testid="global-map">{serverList.length}</div>
),
}));
vi.mock("@/components/GroupSwitch", () => ({
default: ({
tabs,
setCurrentTab,
}: {
tabs: string[];
setCurrentTab: (tab: string) => void;
}) => (
<div>
{tabs.map((tab) => (
<button
key={tab}
type="button"
data-testid={`group-${tab}`}
onClick={() => setCurrentTab(tab)}
>
{tab}
</button>
))}
</div>
),
}));
vi.mock("@/components/ServerOverview", () => ({
default: ({
offline,
online,
total,
}: {
offline: number;
online: number;
total: number;
}) => (
<div data-testid="server-overview">{`${total}:${online}:${offline}`}</div>
),
}));
vi.mock("@/components/ServerCard", () => ({
default: ({ serverInfo }: { serverInfo: NezhaServer }) => (
<article data-testid="server-card">{serverInfo.name}</article>
),
}));
vi.mock("@/components/ServerCardInline", () => ({
default: ({ serverInfo }: { serverInfo: NezhaServer }) => (
<article data-testid="server-card-inline">{serverInfo.name}</article>
),
}));
vi.mock("@/components/ServiceTracker", () => ({
ServiceTracker: ({ serverList }: { serverList: NezhaServer[] }) => (
<div data-testid="service-tracker">{serverList.length}</div>
),
}));
function StatusControl() {
const { setStatus } = useStatus();
return (
<button type="button" onClick={() => setStatus("online")}>
online-only
</button>
);
}
function renderServerPage(
websocketValue: Partial<WebSocketContextType>,
{
backendError = null,
withStatusControl = false,
}: { backendError?: Error | null; withStatusControl?: boolean } = {},
) {
const defaultWebsocketValue: WebSocketContextType = {
lastData: null,
connected: false,
messageHistory: [],
reconnect: vi.fn(),
needReconnect: false,
setNeedReconnect: vi.fn(),
};
return renderWithProviders(
<SortProvider>
<StatusProvider>
<WebSocketContext.Provider
value={{ ...defaultWebsocketValue, ...websocketValue }}
>
{withStatusControl && <StatusControl />}
<Servers backendError={backendError} />
</WebSocketContext.Provider>
</StatusProvider>
</SortProvider>,
);
}
function websocketPayload(servers: NezhaServer[]) {
return {
now: Date.parse("2025-01-01T00:00:20.000Z"),
servers,
};
}
describe("Servers page", () => {
beforeEach(() => {
apiMocks.fetchServerGroup.mockResolvedValue({
success: true,
data: [
{
group: {
id: 1,
created_at: "",
updated_at: "",
name: "Edge",
},
servers: [2],
},
],
});
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {
services: {},
cycle_transfer_stats: {},
},
});
});
it("renders websocket loading and processing states", () => {
const { rerender } = renderServerPage({
connected: false,
lastData: null,
});
expect(screen.getByText("info.websocketConnecting")).toBeInTheDocument();
rerender(
<SortProvider>
<StatusProvider>
<WebSocketContext.Provider
value={{
lastData: null,
connected: true,
messageHistory: [],
reconnect: vi.fn(),
needReconnect: false,
setNeedReconnect: vi.fn(),
}}
>
<Servers />
</WebSocketContext.Provider>
</StatusProvider>
</SortProvider>,
);
expect(screen.getByText("info.processing")).toBeInTheDocument();
});
it("renders a centered backend error instead of a 500 page", async () => {
renderServerPage(
{
connected: false,
lastData: null,
},
{ backendError: new Error("settings failed") },
);
expect(
screen.getByText("error.backendUnavailableTitle"),
).toBeInTheDocument();
expect(
screen.getByText("error.backendUnavailableDescription"),
).toBeInTheDocument();
expect(screen.getByText("settings failed")).toBeInTheDocument();
expect(
screen.queryByText("info.websocketConnecting"),
).not.toBeInTheDocument();
});
it("shows backend query errors while waiting for websocket data", async () => {
apiMocks.fetchServerGroup.mockRejectedValue(new Error("group failed"));
apiMocks.fetchService.mockRejectedValue(new Error("service failed"));
renderServerPage({
connected: false,
lastData: null,
});
expect(
await screen.findByText("error.backendUnavailableTitle"),
).toBeInTheDocument();
expect(screen.getByText("group failed")).toBeInTheDocument();
});
it("summarizes online and offline servers from websocket data", async () => {
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
id: 2,
name: "beta",
last_active: "2024-12-31T23:00:00.000Z",
});
renderServerPage({
connected: true,
lastData: websocketPayload([online, offline]),
});
expect(screen.getByTestId("server-overview")).toHaveTextContent("2:1:1");
expect(screen.getAllByTestId("server-card")).toHaveLength(2);
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
await waitFor(() => {
expect(apiMocks.fetchServerGroup).toHaveBeenCalled();
expect(apiMocks.fetchService).toHaveBeenCalled();
});
});
it("shows an empty state and hides controls when there are no servers", () => {
const { container } = renderServerPage({
connected: true,
lastData: websocketPayload([]),
});
expect(screen.getByTestId("server-overview")).toHaveTextContent("0:0:0");
expect(screen.getByText("info.noServers")).toBeInTheDocument();
expect(
container.querySelector(".server-overview-controls"),
).not.toBeVisible();
expect(screen.queryByTestId("server-card")).not.toBeInTheDocument();
expect(screen.queryByTestId("global-map")).not.toBeInTheDocument();
expect(screen.queryByTestId("service-tracker")).not.toBeInTheDocument();
});
it("shows a filtered empty state while keeping controls available", async () => {
const offline = createServer({
id: 1,
name: "offline",
last_active: "2024-12-31T23:00:00.000Z",
});
const user = userEvent.setup();
const { container } = renderServerPage(
{
connected: true,
lastData: websocketPayload([offline]),
},
{ withStatusControl: true },
);
await user.click(screen.getByRole("button", { name: "online-only" }));
expect(screen.getByText("info.noMatchingServers")).toBeInTheDocument();
expect(container.querySelector(".server-overview-controls")).not.toBeNull();
expect(screen.queryByTestId("server-card")).not.toBeInTheDocument();
});
it("filters servers by selected group", async () => {
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
id: 2,
name: "beta",
last_active: "2024-12-31T23:00:00.000Z",
});
const user = userEvent.setup();
renderServerPage({
connected: true,
lastData: websocketPayload([online, offline]),
});
await user.click(await screen.findByTestId("group-Edge"));
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
expect(sessionStorage.getItem("selectedGroup")).toBe("Edge");
});
it("sorts server cards by selected metrics and direction", async () => {
const lowCpu = createServer({
id: 1,
name: "alpha",
state: { cpu: 10 },
});
const highCpu = createServer({
id: 2,
name: "beta",
state: { cpu: 90 },
});
const user = userEvent.setup();
renderServerPage({
connected: true,
lastData: websocketPayload([lowCpu, highCpu]),
});
await user.selectOptions(screen.getByLabelText("Sort metric"), "cpu");
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("beta");
await user.click(screen.getByLabelText("Toggle sort direction"));
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha");
});
it("keeps name sorting independent from online status", async () => {
const onlineAlpha = createServer({
id: 1,
name: "alpha",
});
const offlineZeta = createServer({
id: 2,
name: "zeta",
last_active: "2024-12-31T23:00:00.000Z",
});
const user = userEvent.setup();
renderServerPage({
connected: true,
lastData: websocketPayload([onlineAlpha, offlineZeta]),
});
expect(screen.getByLabelText("Toggle sort direction")).toBeDisabled();
await user.selectOptions(screen.getByLabelText("Sort metric"), "name");
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("zeta");
await user.click(screen.getByLabelText("Toggle sort direction"));
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha");
});
it("sorts by system even when a platform value is missing", async () => {
const missingPlatform = createServer({
id: 1,
name: "alpha",
host: { platform: undefined },
});
const linux = createServer({
id: 2,
name: "beta",
host: { platform: "linux" },
});
const user = userEvent.setup();
renderServerPage({
connected: true,
lastData: websocketPayload([missingPlatform, linux]),
});
await user.selectOptions(screen.getByLabelText("Sort metric"), "system");
const cards = screen.getAllByTestId("server-card");
expect(cards).toHaveLength(2);
expect(cards[0]).toHaveTextContent("beta");
expect(cards[1]).toHaveTextContent("alpha");
});
it("restores the saved main page scroll position after data is ready", async () => {
const scrollTo = vi.fn();
vi.stubGlobal("scrollTo", scrollTo);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 0;
});
sessionStorage.setItem("fromMainPage", "true");
sessionStorage.setItem("scrollPosition", "345");
renderServerPage({
connected: true,
lastData: websocketPayload([createServer({ id: 1, name: "alpha" })]),
});
await waitFor(() => {
expect(scrollTo).toHaveBeenCalledWith({
top: 345,
left: 0,
behavior: "auto",
});
});
});
it("does not restore stale scroll positions without a main page origin", () => {
const scrollTo = vi.fn();
vi.stubGlobal("scrollTo", scrollTo);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 0;
});
sessionStorage.setItem("scrollPosition", "345");
renderServerPage({
connected: true,
lastData: websocketPayload([createServer({ id: 1, name: "alpha" })]),
});
expect(scrollTo).not.toHaveBeenCalledWith({
top: 345,
left: 0,
behavior: "auto",
});
});
it("renders every card in large server lists without virtualization", () => {
const servers = Array.from({ length: 2000 }, (_, index) =>
createServer({ id: index + 1, name: `server-${index + 1}` }),
);
renderServerPage({
connected: true,
lastData: websocketPayload(servers),
});
expect(screen.getByTestId("server-overview")).toHaveTextContent(
"2000:2000:0",
);
expect(screen.getAllByTestId("server-card")).toHaveLength(2000);
});
it("toggles map and service tracker controls when service data exists", async () => {
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {
services: {
http: {
service_name: "HTTP",
current_up: 1,
current_down: 0,
total_up: 1,
total_down: 0,
delay: [10],
up: [1],
down: [0],
},
},
cycle_transfer_stats: {},
},
});
const user = userEvent.setup();
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
id: 2,
name: "beta",
last_active: "2024-12-31T23:00:00.000Z",
});
const { container } = renderServerPage({
connected: true,
lastData: websocketPayload([online, offline]),
});
await waitFor(() => {
expect(apiMocks.fetchService).toHaveBeenCalled();
expect(
container.querySelectorAll(
".server-overview-controls section > button",
),
).toHaveLength(3);
});
const controls = container.querySelectorAll(
".server-overview-controls section > button",
);
await user.click(controls[0]);
expect(screen.getByTestId("global-map")).toHaveTextContent("2");
expect(localStorage.getItem("showMap")).toBe("1");
await user.click(controls[1]);
expect(screen.getByTestId("service-tracker")).toHaveTextContent("2");
expect(localStorage.getItem("showServices")).toBe("1");
});
it("does not enable inline cards from storage on mobile widths", () => {
localStorage.setItem("inline", "1");
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 500,
});
const online = createServer({ id: 1, name: "alpha" });
renderServerPage({
connected: true,
lastData: websocketPayload([online]),
});
expect(screen.getByTestId("server-card")).toHaveTextContent("alpha");
expect(screen.queryByTestId("server-card-inline")).not.toBeInTheDocument();
});
it("applies external status filters and inline card preferences", async () => {
localStorage.setItem("inline", "1");
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1024,
});
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
id: 2,
name: "beta",
last_active: "2024-12-31T23:00:00.000Z",
});
const user = userEvent.setup();
renderServerPage(
{
connected: true,
lastData: websocketPayload([online, offline]),
},
{ withStatusControl: true },
);
await waitFor(() => {
expect(screen.getAllByTestId("server-card-inline")).toHaveLength(2);
});
await user.click(screen.getByRole("button", { name: "online-only" }));
expect(screen.getAllByTestId("server-card-inline")).toHaveLength(1);
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.queryByText("beta")).not.toBeInTheDocument();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ErrorPage from "@/pages/ErrorPage";
import NotFound from "@/pages/NotFound";
import ServerDetail from "@/pages/ServerDetail";
vi.mock("@/components/NetworkChart", () => ({
NetworkChart: ({ server_id, show }: { server_id: number; show: boolean }) => (
<div data-testid="network-chart">{`${server_id}:${show}`}</div>
),
}));
vi.mock("@/components/ServerDetailChart", () => ({
default: ({ server_id }: { server_id: string }) => (
<div data-testid="detail-chart">{server_id}</div>
),
}));
vi.mock("@/components/ServerDetailOverview", () => ({
default: ({ server_id }: { server_id: string }) => (
<div data-testid="detail-overview">{server_id}</div>
),
}));
vi.mock("@/components/TabSwitch", () => ({
default: ({
tabs,
setCurrentTab,
}: {
tabs: string[];
setCurrentTab: (tab: string) => void;
}) => (
<div>
{tabs.map((tab) => (
<button key={tab} type="button" onClick={() => setCurrentTab(tab)}>
{tab}
</button>
))}
</div>
),
}));
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("simple pages", () => {
it("renders explicit and translated error messages", () => {
const { rerender } = render(<ErrorPage code={418} message="short" />);
expect(screen.getByText("418")).toBeInTheDocument();
expect(screen.getByText("short")).toBeInTheDocument();
rerender(<ErrorPage />);
expect(screen.getByText("error.somethingWentWrong")).toBeInTheDocument();
});
it("navigates back home from the not found page", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/missing"]}>
<Routes>
<Route
path="/missing"
element={
<>
<NotFound />
<LocationProbe />
</>
}
/>
<Route
path="/"
element={
<>
<p>home</p>
<LocationProbe />
</>
}
/>
</Routes>
</MemoryRouter>,
);
expect(screen.getByText("404")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "error.backToHome" }));
expect(screen.getByText("home")).toBeInTheDocument();
expect(screen.getByText("/")).toBeInTheDocument();
});
});
describe("ServerDetail", () => {
beforeEach(() => {
vi.stubGlobal("scrollTo", vi.fn());
});
it("renders detail tab by default and can switch to network tab", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/server/7"]}>
<Routes>
<Route path="/server/:id" element={<ServerDetail />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByTestId("detail-overview")).toHaveTextContent("7");
expect(screen.getByTestId("detail-chart")).toHaveTextContent("7");
expect(screen.queryByTestId("network-chart")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Network" }));
expect(await screen.findByTestId("network-chart")).toHaveTextContent(
"7:true",
);
expect(screen.queryByTestId("detail-chart")).not.toBeInTheDocument();
});
it("redirects when route params are missing", async () => {
render(
<MemoryRouter initialEntries={["/server"]}>
<Routes>
<Route path="/server" element={<ServerDetail />} />
<Route path="/404" element={<p>redirected</p>} />
</Routes>
</MemoryRouter>,
);
expect(await screen.findByText("redirected")).toBeInTheDocument();
});
});

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