mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 09:40:14 +00:00
chore: merge upstream/main into nezha-dash-v1 preserving custom domain and styling features
This commit is contained in:
@@ -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:
|
||||
- "*"
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
run: pnpm run build
|
||||
|
||||
@@ -12,6 +12,7 @@ package-lock.json
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -1,95 +1,50 @@
|
||||
<!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)
|
||||
}
|
||||
|
||||
meta.setAttribute("content", themeColor)
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
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", () => {
|
||||
const root = document.getElementById("root")
|
||||
|
||||
+62
-43
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+2214
-1298
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,37 +42,51 @@ export function DashCommand() {
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, [toggleCommand]);
|
||||
|
||||
if (!connected || !nezhaWsData) return null;
|
||||
const shortcuts = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
keywords: ["home", "homepage"],
|
||||
icon: <Home />,
|
||||
label: t("Home"),
|
||||
action: () => navigate("/"),
|
||||
},
|
||||
{
|
||||
keywords: ["light", "theme", "lightmode"],
|
||||
icon: <Sun />,
|
||||
label: t("ToggleLightMode"),
|
||||
action: () => setTheme("light"),
|
||||
},
|
||||
{
|
||||
keywords: ["dark", "theme", "darkmode"],
|
||||
icon: <Moon />,
|
||||
label: t("ToggleDarkMode"),
|
||||
action: () => setTheme("dark"),
|
||||
},
|
||||
{
|
||||
keywords: ["system", "theme", "systemmode"],
|
||||
icon: <SunMoon />,
|
||||
label: t("ToggleSystemMode"),
|
||||
action: () => setTheme("system"),
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
value: `${item.keywords.join(" ")} ${item.label}`,
|
||||
})),
|
||||
[navigate, setTheme, t],
|
||||
);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
keywords: ["home", "homepage"],
|
||||
icon: <Home />,
|
||||
label: t("Home"),
|
||||
action: () => navigate("/"),
|
||||
},
|
||||
{
|
||||
keywords: ["light", "theme", "lightmode"],
|
||||
icon: <Sun />,
|
||||
label: t("ToggleLightMode"),
|
||||
action: () => setTheme("light"),
|
||||
},
|
||||
{
|
||||
keywords: ["dark", "theme", "darkmode"],
|
||||
icon: <Moon />,
|
||||
label: t("ToggleDarkMode"),
|
||||
action: () => setTheme("dark"),
|
||||
},
|
||||
{
|
||||
keywords: ["system", "theme", "systemmode"],
|
||||
icon: <SunMoon />,
|
||||
label: t("ToggleSystemMode"),
|
||||
action: () => setTheme("system"),
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
value: `${item.keywords.join(" ")} ${item.label}`,
|
||||
}));
|
||||
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" />
|
||||
|
||||
@@ -12,6 +12,7 @@ const Footer: React.FC = () => {
|
||||
queryFn: () => fetchSetting(),
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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: string[] = [];
|
||||
const serverCounts: { [key: string]: number } = {};
|
||||
const { countryList, countryServers, serverCounts } = useMemo(() => {
|
||||
const countryList: string[] = [];
|
||||
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 projection = geoEquirectangular()
|
||||
.scale(140)
|
||||
.translate([width / 2, height / 2])
|
||||
.rotate([-12, 0, 0]);
|
||||
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"
|
||||
|
||||
@@ -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({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
if (currentTagRef && scrollContainer) {
|
||||
const nextScrollLeft =
|
||||
currentTagRef.offsetLeft -
|
||||
scrollContainer.clientWidth / 2 +
|
||||
currentTagRef.offsetWidth / 2;
|
||||
|
||||
if (typeof scrollContainer.scrollTo === "function") {
|
||||
scrollContainer.scrollTo({
|
||||
behavior: "smooth",
|
||||
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>
|
||||
))}
|
||||
|
||||
+35
-23
@@ -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"
|
||||
>
|
||||
<section className="flex items-center gap-1.5">
|
||||
<LoadingSpinner />
|
||||
<p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
|
||||
</section>
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,59 +11,62 @@ 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"
|
||||
key={tooltipData.country}
|
||||
<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: verticalPlacement,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{tooltipData.country === "China"
|
||||
? "Mainland China"
|
||||
: tooltipData.country}
|
||||
</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-xs font-light mb-1">
|
||||
{tooltipData.count} {t("map.Servers")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="border-t dark:border-neutral-700 pt-1"
|
||||
style={{
|
||||
left: tooltipData.centroid[0],
|
||||
top: tooltipData.centroid[1],
|
||||
transform: "translate(20%, -50%)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.stopPropagation();
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{tooltipData.country === "China"
|
||||
? "Mainland China"
|
||||
: tooltipData.country}
|
||||
</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-xs font-light mb-1">
|
||||
{tooltipData.count} {t("map.Servers")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="border-t dark:border-neutral-700 pt-1"
|
||||
style={{
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{tooltipData.servers.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
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");
|
||||
navigate(`/server/${server.id}`);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${server.status ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
<span className="text-xs">{server.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
{tooltipData.servers.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
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={() => {
|
||||
saveMainPageScrollPosition();
|
||||
navigate(`/server/${server.id}`);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${server.status ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
<span className="text-xs">{server.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }[] = [
|
||||
{ value: "1d", label: t("monitor.period1d") },
|
||||
{ value: "7d", label: t("monitor.period7d") },
|
||||
{ value: "30d", label: t("monitor.period30d") },
|
||||
];
|
||||
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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export default function PlanInfo({
|
||||
}: {
|
||||
parsedData: PublicNoteData;
|
||||
}) {
|
||||
if (!parsedData || !parsedData.planDataMod) {
|
||||
if (!parsedData?.planDataMod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }[] = [
|
||||
{ value: "realtime", label: t("serverDetailChart.realtime") },
|
||||
{ value: "1d", label: t("serverDetailChart.period1d") },
|
||||
{ value: "7d", label: t("serverDetailChart.period7d") },
|
||||
{ value: "30d", label: t("serverDetailChart.period30d") },
|
||||
];
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
? `${Math.floor(uptime / 86400)} ${t("serverDetail.days")} ${Math.floor((uptime % 86400) / 3600)} ${t("serverDetail.hours")}`
|
||||
: `${Math.floor(uptime / 3600)} ${t("serverDetail.hours")}`}
|
||||
</div>
|
||||
<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")}`
|
||||
}
|
||||
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,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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", {
|
||||
"bg-card/70": customBackgroundImage,
|
||||
})}
|
||||
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">
|
||||
|
||||
@@ -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,6 +9,32 @@ import { CycleTransferStatsCard } from "./CycleTransferStats";
|
||||
import { Loader } from "./loading/Loader";
|
||||
import ServiceTrackerClient from "./ServiceTrackerClient";
|
||||
|
||||
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;
|
||||
return {
|
||||
completed: up > serviceData.down[index],
|
||||
date: new Date(Date.now() - (29 - index) * 24 * 60 * 60 * 1000),
|
||||
uptime: dailyUptime,
|
||||
delay: serviceData.delay[index] || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const totalUp = serviceData.up.reduce((a, b) => a + b, 0);
|
||||
const totalChecks =
|
||||
serviceData.up.reduce((a, b) => a + b, 0) +
|
||||
serviceData.down.reduce((a, b) => a + b, 0);
|
||||
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
|
||||
: 0;
|
||||
|
||||
return { days, uptime, avgDelay };
|
||||
}
|
||||
|
||||
export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: serviceData, isLoading } = useQuery({
|
||||
@@ -16,34 +43,18 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 10000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const processServiceData = (serviceData: ServiceData) => {
|
||||
const days = serviceData.up.map((up, index) => {
|
||||
const totalChecks = up + serviceData.down[index];
|
||||
const dailyUptime = totalChecks > 0 ? (up / totalChecks) * 100 : 0;
|
||||
return {
|
||||
completed: up > serviceData.down[index],
|
||||
date: new Date(Date.now() - (29 - index) * 24 * 60 * 60 * 1000),
|
||||
uptime: dailyUptime,
|
||||
delay: serviceData.delay[index] || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const totalUp = serviceData.up.reduce((a, b) => a + b, 0);
|
||||
const totalChecks =
|
||||
serviceData.up.reduce((a, b) => a + b, 0) +
|
||||
serviceData.down.reduce((a, b) => a + b, 0);
|
||||
const uptime = (totalUp / totalChecks) * 100;
|
||||
|
||||
const avgDelay =
|
||||
serviceData.delay.length > 0
|
||||
? serviceData.delay.reduce((a, b) => a + b, 0) /
|
||||
serviceData.delay.length
|
||||
: 0;
|
||||
|
||||
return { days, uptime, avgDelay };
|
||||
};
|
||||
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,23 +87,19 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{serviceData.data.services &&
|
||||
Object.keys(serviceData.data.services).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 (
|
||||
<ServiceTrackerClient
|
||||
key={name}
|
||||
days={days}
|
||||
title={data.service_name}
|
||||
uptime={uptime}
|
||||
avgDelay={avgDelay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
{serviceSummaries.length > 0 && (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 mt-4 gap-2 md:gap-4">
|
||||
{serviceSummaries.map(({ avgDelay, days, name, title, uptime }) => (
|
||||
<ServiceTrackerClient
|
||||
key={name}
|
||||
days={days}
|
||||
title={title}
|
||||
uptime={uptime}
|
||||
avgDelay={avgDelay}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
const themeColor =
|
||||
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") {
|
||||
// Time-based theme: 18:00 - 06:00 is dark
|
||||
const isNight = hour >= 18 || hour < 6;
|
||||
effectiveTheme = isNight ? "dark" : "light";
|
||||
} else {
|
||||
effectiveTheme = theme;
|
||||
}
|
||||
|
||||
root.classList.add(effectiveTheme);
|
||||
const themeColor =
|
||||
effectiveTheme === "dark" ? "hsl(30 15% 8%)" : "hsl(0 0% 98%)";
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute("content", themeColor);
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -52,4 +52,4 @@ const AccordionContent = React.forwardRef<
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
{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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -25,4 +25,4 @@ const PopoverContent = React.forwardRef<
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -106,11 +106,11 @@ TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
||||
@@ -24,4 +24,4 @@ const TooltipContent = React.forwardRef<
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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);
|
||||
// 更新历史消息,保持最新的30条记录
|
||||
setMessageHistory((prev) => {
|
||||
const updated = [newMessage, ...prev];
|
||||
return updated.slice(0, 30);
|
||||
});
|
||||
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 = [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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,8 @@ declare global {
|
||||
ForceCardInline: boolean;
|
||||
ForceShowMap: boolean;
|
||||
ForcePeakCutEnabled: boolean;
|
||||
ForceSortType?: string;
|
||||
ForceSortOrder?: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+169
-25
@@ -1,27 +1,171 @@
|
||||
{
|
||||
"nezha": "Monitor Nezha",
|
||||
"overview": "Ringkasan",
|
||||
"dashboard": "Dasbor",
|
||||
"login": "Masuk",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"whereTheTimeIs": "Dimana waktunya",
|
||||
"refreshing": "Menyegarkan",
|
||||
"info": {
|
||||
"websocketConnecting": "WebSocket menghubungkan",
|
||||
"processing": "Memproses...",
|
||||
"websocketDisconnected": "WebSocket terputus",
|
||||
"websocketConnected": "WebSocket terhubung"
|
||||
},
|
||||
"serverOverview": {
|
||||
"totalServers": "Jumlah Server",
|
||||
"onlineServers": "Online Server",
|
||||
"offlineServers": "Offline Server",
|
||||
"speed": "Kecepatan",
|
||||
"totalBandwidth": "Jumlah Bandwidth"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Terang",
|
||||
"dark": "Gelap"
|
||||
}
|
||||
"nezha": "Pemantauan Nezha",
|
||||
"overview": "Ikhtisar",
|
||||
"dashboard": "Dasbor",
|
||||
"login": "Masuk",
|
||||
"online": "Daring",
|
||||
"offline": "Luring",
|
||||
"whereTheTimeIs": "Di mana waktunya",
|
||||
"refreshing": "Menyegarkan",
|
||||
"info": {
|
||||
"websocketConnecting": "WebSocket menghubungkan",
|
||||
"processing": "Memproses...",
|
||||
"websocketDisconnected": "WebSocket terputus",
|
||||
"websocketConnected": "WebSocket terhubung",
|
||||
"noServers": "Tidak ada server yang tersedia",
|
||||
"noMatchingServers": "Tidak ada server yang cocok dengan filter saat ini"
|
||||
},
|
||||
"serverOverview": {
|
||||
"totalServers": "Total Server",
|
||||
"onlineServers": "Server Daring",
|
||||
"offlineServers": "Server Luring",
|
||||
"speed": "Kecepatan",
|
||||
"totalBandwidth": "Total Bandwidth",
|
||||
"network": "Jaringan"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Terang",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "地域"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"nezha": "Monitor-ul Nezha",
|
||||
"theme": {
|
||||
"light": "Mod luminos",
|
||||
"dark": "Mod întunecat"
|
||||
},
|
||||
"Servers": "Servere"
|
||||
}
|
||||
@@ -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": "Скачано"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "மொத்த பதிவிறக்கம்"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "全部"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "全部"
|
||||
}
|
||||
}
|
||||
|
||||
+40
-32
@@ -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,34 +32,36 @@ if (!rootElement) {
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<MotionProvider>
|
||||
<ThemeProvider storageKey="vite-ui-theme">
|
||||
<ThemeColorManager />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WebSocketProvider url="/api/v1/ws/server">
|
||||
<CommandProvider>
|
||||
<StatusProvider>
|
||||
<SortProvider>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
duration={1000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
default:
|
||||
"w-fit rounded-full px-2.5 py-1.5 bg-neutral-100 border border-neutral-200 backdrop-blur-xl shadow-none",
|
||||
},
|
||||
}}
|
||||
position="top-center"
|
||||
className={"flex items-center justify-center"}
|
||||
/>
|
||||
<ReactQueryDevtools />
|
||||
</TooltipProvider>
|
||||
</SortProvider>
|
||||
</StatusProvider>
|
||||
</CommandProvider>
|
||||
</WebSocketProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</MotionProvider>,
|
||||
<ThemeProvider storageKey="vite-ui-theme">
|
||||
<ThemeColorManager />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WebSocketProvider url="/api/v1/ws/server">
|
||||
<CommandProvider>
|
||||
<StatusProvider>
|
||||
<SortProvider>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
duration={1000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
default:
|
||||
"w-fit rounded-full px-2.5 py-1.5 bg-neutral-100 border border-neutral-200 backdrop-blur-xl shadow-none",
|
||||
},
|
||||
}}
|
||||
position="top-center"
|
||||
className={"flex items-center justify-center"}
|
||||
/>
|
||||
{ReactQueryDevtools ? (
|
||||
<Suspense fallback={null}>
|
||||
<ReactQueryDevtools />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
</SortProvider>
|
||||
</StatusProvider>
|
||||
</CommandProvider>
|
||||
</WebSocketProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
+436
-303
@@ -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);
|
||||
}, []);
|
||||
|
||||
restoreScrollPosition();
|
||||
}, [restoreScrollPosition]);
|
||||
useEffect(() => {
|
||||
if (nezhaWsData) {
|
||||
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 = [
|
||||
"All",
|
||||
...(groupData?.data
|
||||
?.filter((item: ServerGroup) => {
|
||||
return (
|
||||
Array.isArray(item.servers) &&
|
||||
item.servers.some((serverId) =>
|
||||
nezhaWsData?.servers?.some((server) => server.id === serverId),
|
||||
)
|
||||
);
|
||||
})
|
||||
?.map((item: ServerGroup) => item.group.name) || []),
|
||||
];
|
||||
for (const item of groupData?.data ?? []) {
|
||||
if (Array.isArray(item.servers)) {
|
||||
sets.set(item.group.name, new Set(item.servers));
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected && !lastMessage) {
|
||||
return sets;
|
||||
}, [groupData?.data]);
|
||||
|
||||
const groupTabs = useMemo(
|
||||
() => [
|
||||
"All",
|
||||
...(groupData?.data?.map((item: ServerGroup) => item.group.name) || []),
|
||||
],
|
||||
[groupData?.data],
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,29 +490,31 @@ export default function Servers() {
|
||||
},
|
||||
)}
|
||||
>
|
||||
<MapIcon className={cn("size-[13px]")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowServices(showServices === "0" ? "1" : "0");
|
||||
localStorage.setItem(
|
||||
"showServices",
|
||||
showServices === "0" ? "1" : "0",
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-2.5 text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100",
|
||||
{
|
||||
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
|
||||
showServices === "1",
|
||||
},
|
||||
{
|
||||
"bg-opacity-70 dark:bg-opacity-70": customBackgroundImage,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ChartBarSquareIcon className={cn("size-[13px]")} />
|
||||
<MapIcon className="size-[13px]" />
|
||||
</button>
|
||||
{hasServices && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowServices(showServices === "0" ? "1" : "0");
|
||||
localStorage.setItem(
|
||||
"showServices",
|
||||
showServices === "0" ? "1" : "0",
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-2.5 text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100",
|
||||
{
|
||||
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
|
||||
showServices === "1",
|
||||
},
|
||||
{
|
||||
"bg-opacity-70 dark:bg-opacity-70": customBackgroundImage,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<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
|
||||
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)] ",
|
||||
{
|
||||
"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 ":
|
||||
customBackgroundImage,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] font-bold whitespace-nowrap">
|
||||
{sortType === "default" ? "Sort" : sortType.toUpperCase()}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
{
|
||||
"dark:border-stone-600/80 dark:bg-stone-800/80 bg-white/75":
|
||||
customBackgroundImage,
|
||||
},
|
||||
{
|
||||
" text-blue-600 dark:text-blue-400": sortType !== "default",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div className="text-stone-900 dark:text-stone-100">
|
||||
{sortOrder === "asc" && sortType !== "default" ? (
|
||||
<ArrowUpIcon className="size-[13px]" />
|
||||
<ArrowUpIcon className="size-3.5 shrink-0" />
|
||||
) : sortOrder === "desc" && sortType !== "default" ? (
|
||||
<ArrowDownIcon className="size-[13px]" />
|
||||
<ArrowDownIcon className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ArrowsUpDownIcon className="size-[13px]" />
|
||||
<ArrowsUpDownIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
</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}
|
||||
disabled={sortType === "default"}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{showMap === "1" && (
|
||||
<GlobalMap
|
||||
now={nezhaWsData.now}
|
||||
serverList={nezhaWsData?.servers || []}
|
||||
/>
|
||||
{hasServers && showMap === "1" && (
|
||||
<GlobalMap now={nezhaWsData.now} serverList={nezhaWsData.servers} />
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
{filteredServers.map((serverInfo) => (
|
||||
<ServerCardInline
|
||||
now={nezhaWsData.now}
|
||||
key={serverInfo.id}
|
||||
serverInfo={serverInfo}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
{hasServers && showServices === "1" && (
|
||||
<ServiceTracker serverList={filteredServers} />
|
||||
)}
|
||||
{inline === "0" && (
|
||||
<section
|
||||
ref={containerRef}
|
||||
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}
|
||||
serverInfo={serverInfo}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
{!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
|
||||
key={serverInfo.id}
|
||||
now={nezhaWsData.now}
|
||||
serverInfo={serverInfo}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<section className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6 server-card-list">
|
||||
{filteredServers.map((serverInfo) => (
|
||||
<ServerCard
|
||||
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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("CN2|CMI")).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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user