diff --git a/README.md b/README.md index c793e10..fa0bcde 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,28 @@ > [!NOTE] > 此项目为 nezha-dash 的官方实现,作为哪吒监控的默认前端,功能上可能与 nezha-dash 有所不同。 > https://github.com/hamster1963/nezha-dash + +## 本地调整 UI + +使用 Node.js >= 22、pnpm >= 11,在项目目录运行: + +```bash +pnpm install +MOCK_NEZHA_COUNT=6 pnpm dev:mock +``` + +该命令同时启动本地 mock API(8008 端口)和 Vite,使用仓库 `.cert` 下的 HTTPS 证书。打开终端显示的地址(通常为 `https://localhost:5173`);若浏览器提示本地证书不受信任,允许访问本地开发地址。 + +进入 `/server/1`,点击「在线 / Online」标签。在线历史支持 1/7/30 天切换、时间轴提示、在线/离线时段和「展开更多」。数据按当前时间生成,使用 `Asia/Shanghai` 时区;查询窗口是最近 N × 24 小时,因此每日列表通常会包含首尾两个不完整日期。 + +| 详情页 | 预览场景 | +| --- | --- | +| `/server/1`、`/server/6` | 正常在线,包含短暂掉线和跨日会话 | +| `/server/2` | 持续在线,在线率 100% | +| `/server/3` | 当前离线,包含进行中的离线时段 | +| `/server/4` | 无在线历史,在线率 0%,最后活跃显示 N/A | +| `/server/5` | 频繁掉线,用于查看密集时段标签和换行 | + +主要样式位于 `src/components/ServerDetailOnlineHistory.tsx` 的 `className`,保存后自动热更新;桌面与手机布局的切换点是 768px。修改 mock 脚本后需重启 `pnpm dev:mock`,按 `Ctrl+C` 可停止前端和 mock 服务。 + +在线历史 mock 允许查询最多 30 天数据,无需真实后端或登录。不要同时运行占用 8008 端口的后端;省略 `MOCK_NEZHA_COUNT` 时仍使用原有的 3000 节点规模。 diff --git a/index.html b/index.html index 1f32b8a..159f377 100644 --- a/index.html +++ b/index.html @@ -74,8 +74,6 @@ - -
diff --git a/package.json b/package.json index 98de67a..2dc6928 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nazha-dash-v2", "private": true, - "version": "2.0.2", + "version": "2.5.1", "type": "module", "engines": { "node": ">=22", @@ -52,6 +52,7 @@ "country-flag-icons": "1.6.17", "d3-geo": "3.1.1", "dayjs": "1.11.21", + "flag-icons": "7.0.0", "i18n-iso-countries": "7.14.0", "i18next": "26.3.1", "lucide-react": "1.17.0", @@ -59,7 +60,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-i18next": "17.0.8", - "react-router-dom": "^7.18.1", + "react-router-dom": "^7.18.4", "recharts": "3.8.1", "sonner": "2.0.7", "tailwind-merge": "3.6.0", @@ -75,14 +76,14 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "^6.0.3", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.12", "globals": "17.6.0", "jsdom": "^29.1.1", - "postcss": "8.5.15", + "postcss": "8.5.31", "tailwindcss": "^4.3.2", "typescript": "^7.0.2", - "vite": "^8.1.3", - "vitest": "^4.1.9", + "vite": "^8.1.6", + "vitest": "^4.1.12", "ws": "^8.21.0" }, "packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84a3c55..5fbc3d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: dayjs: specifier: 1.11.21 version: 1.11.21 + flag-icons: + specifier: 7.0.0 + version: 7.0.0 i18n-iso-countries: specifier: 7.14.0 version: 7.14.0 @@ -108,8 +111,8 @@ importers: specifier: 17.0.8 version: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) react-router-dom: - specifier: ^7.18.1 - version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^7.18.4 + version: 7.18.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) recharts: specifier: 3.8.1 version: 3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@18.3.1)(react@19.2.7)(redux@5.0.1) @@ -131,13 +134,13 @@ importers: version: 4.3.2 '@testing-library/jest-dom': specifier: ^6.9.1 - version: 6.9.1 + version: 6.10.0(@testing-library/dom@10.4.1) '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@testing-library/user-event': specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) + version: 14.6.8(@testing-library/dom@10.4.1) '@types/node': specifier: 25.9.2 version: 25.9.2 @@ -149,10 +152,10 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.3 - version: 6.0.3(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)) + version: 6.0.3(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)) '@vitest/coverage-v8': - specifier: ^4.1.9 - version: 4.1.9(vitest@4.1.9) + specifier: ^4.1.12 + version: 4.1.12(vitest@4.1.12) globals: specifier: 17.6.0 version: 17.6.0 @@ -160,8 +163,8 @@ importers: specifier: ^29.1.1 version: 29.1.1 postcss: - specifier: 8.5.15 - version: 8.5.15 + specifier: 8.5.31 + version: 8.5.31 tailwindcss: specifier: ^4.3.2 version: 4.3.2 @@ -169,11 +172,11 @@ importers: specifier: ^7.0.2 version: 7.0.2 vite: - specifier: ^8.1.3 - version: 8.1.3(@types/node@25.9.2)(jiti@2.7.0) + specifier: ^8.1.6 + version: 8.1.6(@types/node@25.9.2)(jiti@2.7.0) vitest: - specifier: ^4.1.9 - version: 4.1.9(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)) + specifier: ^4.1.12 + version: 4.1.12(@types/node@25.9.2)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1)(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)) ws: specifier: ^8.21.0 version: 8.21.0 @@ -214,17 +217,21 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -379,17 +386,18 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + '@napi-rs/wasm-runtime@1.2.5': + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 '@number-flow/react@0.5.5': resolution: {integrity: sha512-Zdju5n0osxrb+7jbcpUJ9L2VJ2+9ptwjz5+A+2wq9Q32hs3PW/noPJjHtLTrtGINM9mEw76DcDg0ac/dx6j1aA==} @@ -397,8 +405,8 @@ packages: react: ^18 || ^19 react-dom: ^18 || ^19 - '@oxc-project/types@0.138.0': - resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -906,97 +914,97 @@ packages: react-redux: optional: true - '@rolldown/binding-android-arm64@1.1.4': - resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.4': - resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.4': - resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.4': - resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': - resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.4': - resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.4': - resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.4': - resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.4': - resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.4': - resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.4': - resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.4': - resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.4': - resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.1.4': - resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.4': - resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1134,9 +1142,12 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' '@testing-library/react@16.3.2': resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} @@ -1153,14 +1164,14 @@ packages: '@types/react-dom': optional: true - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + '@testing-library/user-event@14.6.8': + resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1357,20 +1368,20 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/coverage-v8@4.1.9': - resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + '@vitest/coverage-v8@4.1.12': + resolution: {integrity: sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==} peerDependencies: - '@vitest/browser': 4.1.9 - vitest: 4.1.9 + '@vitest/browser': 4.1.12 + vitest: 4.1.12 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/expect@4.1.12': + resolution: {integrity: sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==} - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + '@vitest/mocker@4.1.12': + resolution: {integrity: sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1380,20 +1391,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/pretty-format@4.1.12': + resolution: {integrity: sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/runner@4.1.12': + resolution: {integrity: sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/snapshot@4.1.12': + resolution: {integrity: sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/spy@4.1.12': + resolution: {integrity: sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vitest/utils@4.1.12': + resolution: {integrity: sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -1418,14 +1429,14 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@1.0.4: - resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} class-variance-authority@0.7.1: @@ -1550,8 +1561,8 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - es-module-lexer@2.3.0: - resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -1578,6 +1589,9 @@ packages: picomatch: optional: true + flag-icons@7.0.0: + resolution: {integrity: sha512-U8nkYAJUHDro4SiKjJgnVJnAr6yMHWG7k6+FnV73dnWf/HkQXOZLxkie8qd4ZWR83+L6hQITEWJYgApN4sfUZA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1674,30 +1688,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -1705,6 +1749,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -1712,6 +1763,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -1719,6 +1777,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -1726,22 +1791,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -1762,8 +1850,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -1776,16 +1864,16 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true number-flow@0.5.3: resolution: {integrity: sha512-iLKyssImNWQmJ41rza9K7P5lHRZTyishi/9FarWPLQHYY2Ydtl6eiXINEjZ1fa8dHeY0O7+YOD+Py3ZsJddYkg==} - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} parse5@8.0.1: @@ -1797,16 +1885,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} pretty-format@27.5.1: @@ -1876,15 +1960,15 @@ packages: '@types/react': optional: true - react-router-dom@7.18.1: - resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + react-router-dom@7.18.4: + resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.18.1: - resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + react-router@7.18.4: + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -1934,8 +2018,8 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} - rolldown@1.1.4: - resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1963,15 +2047,15 @@ packages: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} @@ -2005,16 +2089,16 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} tldts-core@7.4.6: @@ -2043,8 +2127,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} engines: {node: '>=20.18.1'} use-callback-ref@1.3.3: @@ -2075,8 +2159,8 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + vite@8.1.6: + resolution: {integrity: sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2118,20 +2202,20 @@ packages: yaml: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.12: + resolution: {integrity: sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.12 + '@vitest/browser-preview': 4.1.12 + '@vitest/browser-webdriverio': ^4.1.11 + '@vitest/coverage-istanbul': 4.1.12 + '@vitest/coverage-v8': 4.1.12 + '@vitest/ui': 4.1.12 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2239,13 +2323,15 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@babel/parser@7.29.7': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.10': {} '@babel/runtime@7.29.7': {} - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -2358,7 +2444,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -2368,18 +2454,18 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 + '@tybys/wasm-util': 0.10.4 optional: true '@number-flow/react@0.5.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': @@ -2389,7 +2475,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@oxc-project/types@0.138.0': {} + '@oxc-project/types@0.139.0': {} '@radix-ui/number@1.1.2': {} @@ -2896,53 +2982,53 @@ snapshots: react: 19.2.7 react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) - '@rolldown/binding-android-arm64@1.1.4': + '@rolldown/binding-android-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-arm64@1.1.4': + '@rolldown/binding-darwin-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-x64@1.1.4': + '@rolldown/binding-darwin-x64@1.1.5': optional: true - '@rolldown/binding-freebsd-x64@1.1.4': + '@rolldown/binding-freebsd-x64@1.1.5': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.4': + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.4': + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.4': + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.4': + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.4': + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-musl@1.1.4': + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true - '@rolldown/binding-openharmony-arm64@1.1.4': + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true - '@rolldown/binding-wasm32-wasi@1.1.4': + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.4': + '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.4': + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -2958,7 +3044,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.2 '@tailwindcss/oxide-android-arm64@4.3.2': @@ -3017,7 +3103,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 - postcss: 8.5.15 + postcss: 8.5.31 tailwindcss: 4.3.2 '@tanstack/query-core@5.101.0': {} @@ -3046,7 +3132,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -3054,9 +3140,10 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.9.1': + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': dependencies: '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 aria-query: 5.3.2 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 @@ -3073,11 +3160,11 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 - '@tybys/wasm-util@0.10.3': + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 optional: true @@ -3199,65 +3286,65 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react@6.0.3(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.3(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.3(@types/node@25.9.2)(jiti@2.7.0) + vite: 8.1.6(@types/node@25.9.2)(jiti@2.7.0) - '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + '@vitest/coverage-v8@4.1.12(vitest@4.1.12)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.9 - ast-v8-to-istanbul: 1.0.4 + '@vitest/utils': 4.1.12 + ast-v8-to-istanbul: 1.0.7 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.3 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)) + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 4.1.12(@types/node@25.9.2)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1)(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)) - '@vitest/expect@4.1.9': + '@vitest/expect@4.1.12': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - chai: 6.2.2 - tinyrainbow: 3.1.0 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 + chai: 6.3.0 + tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.9(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0))': + '@vitest/mocker@4.1.12(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@25.9.2)(jiti@2.7.0) + vite: 8.1.6(@types/node@25.9.2)(jiti@2.7.0) - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.12': dependencies: - tinyrainbow: 3.1.0 + tinyrainbow: 3.2.0 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.12': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.12 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.12 + '@vitest/utils': 4.1.12 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.12': {} - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.12 convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 + tinyrainbow: 3.2.0 ansi-regex@5.0.1: {} @@ -3275,7 +3362,7 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@1.0.4: + ast-v8-to-istanbul@1.0.7: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -3285,7 +3372,7 @@ snapshots: dependencies: require-from-string: 2.0.2 - chai@6.2.2: {} + chai@6.3.0: {} class-variance-authority@0.7.1: dependencies: @@ -3314,7 +3401,7 @@ snapshots: css-tree@3.2.1: dependencies: mdn-data: 2.27.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css.escape@1.5.1: {} @@ -3394,7 +3481,7 @@ snapshots: entities@8.0.0: {} - es-module-lexer@2.3.0: {} + es-module-lexer@2.3.2: {} es-toolkit@1.49.0: {} @@ -3408,9 +3495,11 @@ snapshots: expect-type@1.4.0: {} - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 + + flag-icons@7.0.0: {} fsevents@2.3.3: optional: true @@ -3489,7 +3578,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.28.0 + undici: 7.30.1 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -3501,36 +3590,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -3547,6 +3669,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lru-cache@11.5.1: {} lucide-react@1.17.0(react@19.2.7): @@ -3559,13 +3697,13 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - magicast@0.5.3: + magicast@0.5.5: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - source-map-js: 1.2.1 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -3575,13 +3713,13 @@ snapshots: min-indent@1.0.1: {} - nanoid@3.3.15: {} + nanoid@3.3.20: {} number-flow@0.5.3: dependencies: esm-env: 1.2.2 - obug@2.1.3: {} + obug@2.2.1: {} parse5@8.0.1: dependencies: @@ -3591,19 +3729,13 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} - postcss@8.5.15: + postcss@8.5.31: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.16: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 pretty-format@27.5.1: dependencies: @@ -3661,13 +3793,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-router-dom@7.18.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-router: 7.18.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-router@7.18.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: cookie: 1.1.1 react: 19.2.7 @@ -3720,26 +3852,26 @@ snapshots: reselect@5.1.1: {} - rolldown@1.1.4: + rolldown@1.1.5: dependencies: - '@oxc-project/types': 0.138.0 + '@oxc-project/types': 0.139.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.4 - '@rolldown/binding-darwin-arm64': 1.1.4 - '@rolldown/binding-darwin-x64': 1.1.4 - '@rolldown/binding-freebsd-x64': 1.1.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 - '@rolldown/binding-linux-arm64-gnu': 1.1.4 - '@rolldown/binding-linux-arm64-musl': 1.1.4 - '@rolldown/binding-linux-ppc64-gnu': 1.1.4 - '@rolldown/binding-linux-s390x-gnu': 1.1.4 - '@rolldown/binding-linux-x64-gnu': 1.1.4 - '@rolldown/binding-linux-x64-musl': 1.1.4 - '@rolldown/binding-openharmony-arm64': 1.1.4 - '@rolldown/binding-wasm32-wasi': 1.1.4 - '@rolldown/binding-win32-arm64-msvc': 1.1.4 - '@rolldown/binding-win32-x64-msvc': 1.1.4 + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 saxes@6.0.0: dependencies: @@ -3758,11 +3890,11 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} stackback@0.0.2: {} - std-env@4.1.0: {} + std-env@4.3.0: {} strip-indent@3.0.0: dependencies: @@ -3788,14 +3920,14 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.2.4: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 - tinyrainbow@3.1.0: {} + tinyrainbow@3.2.0: {} tldts-core@7.4.6: {} @@ -3838,7 +3970,7 @@ snapshots: undici-types@7.24.6: {} - undici@7.28.0: {} + undici@7.30.1: {} use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): dependencies: @@ -3876,43 +4008,43 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0): + vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.5 - postcss: 8.5.16 - rolldown: 1.1.4 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.2 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.9(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)): + vitest@4.1.12(@types/node@25.9.2)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1)(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - es-module-lexer: 2.3.0 + '@vitest/expect': 4.1.12 + '@vitest/mocker': 4.1.12(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.12 + '@vitest/runner': 4.1.12 + '@vitest/snapshot': 4.1.12 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.2.1 pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.1.0 + picomatch: 4.0.7 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@25.9.2)(jiti@2.7.0) + tinyrainbow: 3.2.0 + vite: 8.1.6(@types/node@25.9.2)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.2 - '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + '@vitest/coverage-v8': 4.1.12(vitest@4.1.12) jsdom: 29.1.1 transitivePeerDependencies: - msw diff --git a/scripts/mock-nezha-server.js b/scripts/mock-nezha-server.js index 1edd80a..de5c5c3 100644 --- a/scripts/mock-nezha-server.js +++ b/scripts/mock-nezha-server.js @@ -7,6 +7,13 @@ const serverCount = Number(process.env.MOCK_NEZHA_COUNT || 3000); const updateIntervalMs = Number( process.env.MOCK_NEZHA_INTERVAL_MS || (serverCount > 10_000 ? 0 : 2000), ); +const dayMs = 86_400_000; +const minuteMs = 60_000; +const timezoneOffsetMs = 8 * 60 * minuteMs; +const startedAt = Math.floor(Date.now() / 1000) * 1000; + +// 前几个节点提供固定 UI 场景;其余节点保留每 17 个离线一个的规则。 +const isServerOffline = (id) => id === 3 || id === 4 || id % 17 === 0; const countries = [ "US", @@ -40,14 +47,16 @@ function createServer(index) { 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; + const isOffline = isServerOffline(id); 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() + ? new Date( + startedAt - (id === 4 ? 31 * dayMs : 10 * minuteMs), + ).toISOString() : nowIso(), country_code: countries[index % countries.length], host: { @@ -87,14 +96,16 @@ function createServer(index) { const servers = Array.from({ length: serverCount }, (_, index) => createServer(index), ); -let onlineCount = servers.filter((server) => server.id % 17 !== 0).length; +let onlineCount = servers.filter( + (server) => !isServerOffline(server.id), +).length; function updateServerMetrics() { const now = Date.now(); let nextOnlineCount = 0; for (const server of servers) { - const isOffline = server.id % 17 === 0; + const isOffline = isServerOffline(server.id); if (!isOffline) { server.last_active = new Date(now).toISOString(); nextOnlineCount += 1; @@ -126,8 +137,8 @@ function websocketPayload() { return JSON.stringify(payload); } -function jsonResponse(response, data) { - response.writeHead(200, { +function jsonResponse(response, data, statusCode = 200) { + response.writeHead(statusCode, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", "Content-Type": "application/json; charset=utf-8", @@ -135,6 +146,92 @@ function jsonResponse(response, data) { response.end(JSON.stringify(data)); } +function createOnlineHistory(server, days) { + const windowEnd = Math.floor(Date.now() / 1000) * 1000; + const windowStart = windowEnd - days * dayMs; + const firstDay = + Math.floor((windowStart + timezoneOffsetMs) / dayMs) * dayMs - + timezoneOffsetMs; + const currentlyOnline = !isServerOffline(server.id); + const onlineEnd = currentlyOnline + ? windowEnd + : Date.parse(server.last_active); + const periods = []; + const daily = []; + + const addPeriod = (start, end) => { + if (end <= start) return; + periods.push({ + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + ongoing: currentlyOnline && end === windowEnd, + }); + }; + + // 1/6: 日常短暂掉线;2: 持续在线;3: 当前离线;4: 无历史;5: 密集短时段。 + // 先生成离线间隙,再取补集,跨午夜的在线会话保持连续。 + if (server.id !== 4) { + let cursor = windowStart; + for (let day = firstDay; day < onlineEnd; day += dayMs) { + if (server.id === 2) continue; + const dayIndex = Math.floor((day + timezoneOffsetMs) / dayMs); + const hours = + server.id === 5 + ? [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22] + : [2, 8, 15]; + for (const hour of hours) { + const gapStart = Math.max(windowStart, day + hour * 60 * minuteMs); + const gapMinutes = + server.id === 5 ? 35 : 10 + ((dayIndex + server.id + hour) % 6) * 13; + // 最后活跃前半小时保持在线,让时段终点与 WebSocket 状态一致。 + const gapEnd = Math.min( + day + (hour * 60 + gapMinutes) * minuteMs, + onlineEnd - 30 * minuteMs, + ); + if (gapEnd <= gapStart) continue; + addPeriod(cursor, gapStart); + cursor = gapEnd; + } + } + addPeriod(cursor, onlineEnd); + } + + // 每日数字和概要都从同一组时段计算,避免时间轴与统计不一致。 + for (let day = firstDay; day < windowEnd; day += dayMs) { + const onlineSeconds = periods.reduce((total, period) => { + const start = Math.max(day, Date.parse(period.start)); + const end = Math.min(day + dayMs, Date.parse(period.end)); + return total + Math.max(0, (end - start) / 1000); + }, 0); + daily.push({ + date: new Date(day + timezoneOffsetMs).toISOString().slice(0, 10), + online_seconds: onlineSeconds, + }); + } + const totalOnlineSeconds = daily.reduce( + (total, day) => total + day.online_seconds, + 0, + ); + + return { + server_id: server.id, + server_name: server.name, + days, + max_days: 30, + timezone: "Asia/Shanghai", + window_start: new Date(windowStart).toISOString(), + window_end: new Date(windowEnd).toISOString(), + total_online_seconds: totalOnlineSeconds, + online_rate: + (totalOnlineSeconds / ((windowEnd - windowStart) / 1000)) * 100, + currently_online: currentlyOnline, + last_active: + server.id === 4 ? undefined : new Date(onlineEnd).toISOString(), + periods, + daily, + }; +} + const populatedServerGroups = [ { group: { @@ -211,6 +308,35 @@ const httpServer = http.createServer((request, response) => { return; } + const onlineHistoryMatch = requestUrl.pathname.match( + /^\/api\/v1\/server\/(\d+)\/online-history$/, + ); + if (onlineHistoryMatch) { + const server = servers[Number(onlineHistoryMatch[1]) - 1]; + const days = Number(requestUrl.searchParams.get("days") ?? 7); + if (!server) { + jsonResponse( + response, + { success: false, error: "server not found" }, + 404, + ); + return; + } + if (!Number.isInteger(days) || days < 1 || days > 30) { + jsonResponse( + response, + { success: false, error: "days must be an integer between 1 and 30" }, + 400, + ); + return; + } + jsonResponse(response, { + success: true, + data: createOnlineHistory(server, days), + }); + return; + } + switch (requestUrl.pathname) { case "/api/v1/setting": jsonResponse(response, { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e4bbb2e..0c892e9 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -12,7 +12,6 @@ import { useWebSocketContext } from "@/hooks/use-websocket-context"; import { fetchLoginUser, fetchSetting } from "@/lib/nezha-api"; import { cn } from "@/lib/utils"; -import AnimateCountClient from "./AnimatedCount"; import { LanguageSwitcher } from "./LanguageSwitcher"; import { Loader, LoadingSpinner } from "./loading/Loader"; import NumericText from "./NumericText"; @@ -355,12 +354,18 @@ function Overview() {

{t("whereTheTimeIs")}

{mounted ? (
- + + {String(time.hh).padStart(2, "0")} + : - + + {String(time.mm).padStart(2, "0")} + : - + + {String(time.ss).padStart(2, "0")} +
) : ( diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 1d7cd2f..1319ea0 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -34,6 +34,13 @@ export function LanguageSwitcher() { { name: t("language.es-ES"), code: "es-ES" }, { name: t("language.de-DE"), code: "de-DE" }, { name: t("language.ta-IN"), code: "ta-IN" }, + { name: t("language.fr"), code: "fr" }, + { name: t("language.gl"), code: "gl" }, + { name: t("language.id"), code: "id" }, + { name: t("language.ja"), code: "ja" }, + { name: t("language.pt_BR"), code: "pt_BR" }, + { name: t("language.ro"), code: "ro" }, + { name: t("language.uk"), code: "uk" }, ]; return ( diff --git a/src/components/ServerDetailChart.tsx b/src/components/ServerDetailChart.tsx index 2a829e4..c22c671 100644 --- a/src/components/ServerDetailChart.tsx +++ b/src/components/ServerDetailChart.tsx @@ -274,6 +274,8 @@ export default function ServerDetailChart({ const gpuStats = server.state.gpu || []; const gpuList = server.host.gpu || []; + // Newer agents also report memory per card, index-aligned with the above. + const gpuDetails = server.state.gpus || []; return (
@@ -298,6 +300,7 @@ export default function ServerDetailChart({ now={nezhaWsData.now} gpuStat={gpuStats[index]} gpuName={gpu} + gpuMemory={gpuDetails[index]} messageHistory={messageHistory} period={selectedPeriod} key={index} @@ -311,6 +314,7 @@ export default function ServerDetailChart({ now={nezhaWsData.now} gpuStat={gpu} gpuName={`#${index + 1}`} + gpuMemory={gpuDetails[index]} messageHistory={messageHistory} period={selectedPeriod} key={index} @@ -426,6 +430,7 @@ function GpuChart({ index, gpuStat, gpuName, + gpuMemory, messageHistory, period, }: { @@ -434,6 +439,7 @@ function GpuChart({ index: number; gpuStat: number; gpuName?: string; + gpuMemory?: { memory_used?: number; memory_total?: number }; messageHistory: NezhaWebsocketResponse[]; period: ChartPeriod; }) { @@ -532,6 +538,14 @@ function GpuChart({
{!gpuName &&

GPU

} {gpuName &&

GPU: {gpuName}

} + {gpuMemory?.memory_total ? ( +

+ {gpuMemory.memory_used != null + ? formatBytes(gpuMemory.memory_used * 1024 * 1024) + : "—"}{" "} + / {formatBytes(gpuMemory.memory_total * 1024 * 1024)} +

+ ) : null}

diff --git a/src/components/ServerDetailOnlineHistory.tsx b/src/components/ServerDetailOnlineHistory.tsx new file mode 100644 index 0000000..48fbde4 --- /dev/null +++ b/src/components/ServerDetailOnlineHistory.tsx @@ -0,0 +1,784 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useActiveIndicator } from "@/hooks/use-active-indicator"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { useWebSocketContext } from "@/hooks/use-websocket-context"; +import { fetchServerOnlineHistory } from "@/lib/nezha-api"; +import { + buildDailyOnlineRows, + buildOfflineGroups, + formatDuration, + formatPeriodMoment, + groupOnlineRows, + panelDay, + panelNow, +} from "@/lib/online-history"; +import { cn } from "@/lib/utils"; + +const DAY_OPTIONS = [1, 7, 30]; +// Online periods 默认展开最近 3 天,每次「展开更多」追加 10 天 +const PERIODS_INITIAL_DAYS = 3; +const PERIODS_EXPAND_STEP = 10; + +type PeriodMode = "online" | "offline"; + +// 与详情页一致的轻量指标标签。 +function SummaryLabel({ children }: { children: ReactNode }) { + return {children}; +} + +// 四项指标共用行高,辅助信息在底部对齐。 +function SummaryMetric({ + label, + children, + footer, +}: { + label: ReactNode; + children: ReactNode; + footer: ReactNode; +}) { + return ( +

+ {label} +
+ {children} +
+
+ {footer} +
+
+ ); +} + +// 概要卡的信息盒:标签在上、数值在下 +function SummaryBox({ + label, + children, +}: { + label: ReactNode; + children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +function WindowShade({ + left, + width, + label, + striped = false, +}: { + left: number; + width: number; + label: string; + striped?: boolean; +}) { + if (width <= 0) return null; + return ( + + + + + + {t("serverOnlineHistory.dataCutoff")}{" "} + {day.window.end.format("HH:mm:ss")} + + + )} + +
+ {[0, 6, 12, 18, 24].map((hour) => ( + {hour} + ))} +
+ + ); + })} + +
+ + + {t("serverOnlineHistory.outsideWindow")} + + + + {t("serverOnlineHistory.notYetRecorded")} + + {t("serverOnlineHistory.dataCutoffHint")} +
+ + + + + + +
+

+ {t( + periodMode === "online" + ? "serverOnlineHistory.periods" + : "serverOnlineHistory.offlinePeriods", + )} +

+ { + setPeriodMode(nextMode); + setVisibleDays(PERIODS_INITIAL_DAYS); + }} + /> +
+ {(() => { + if (!online) { + return null; + } + const groups = periodGroups; + const visibleGroups = groups.slice(0, visibleDays); + if (groups.length === 0) { + return ( +

+ {t("serverOnlineHistory.noData")} +

+ ); + } + return ( + +
+ {visibleGroups.map((group) => ( +
+
+ + {group.day.format("MM-DD")} + {group.day.isSame(panelNow(tz), "day") && ( + + {" · "} + {t("serverOnlineHistory.today")} + + )} + + + {t(periodMode === "online" ? "online" : "offline")}{" "} + {formatDuration(group.total)} + +
+
+ {group.chips.map((chip, index) => { + const seconds = chip.end.diff(chip.start, "second"); + return ( + + + + {chip.ongoing && ( + + )} + {chip.start.format("HH:mm")} + {" → "} + {chip.ongoing + ? t("serverOnlineHistory.ongoing") + : formatPeriodMoment( + chip.end, + chip.start, + tz, + )} + + {formatDuration(seconds)} + + + + + {chip.start.format("HH:mm:ss")} + {" → "} + {chip.ongoing + ? t("serverOnlineHistory.ongoing") + : formatPeriodMoment( + chip.end, + chip.start, + tz, + true, + )} + {" · "} + {formatDuration(seconds)} + {chip.original && + (!chip.start.isSame( + panelDay(chip.original.start, tz), + ) || + !chip.end.isSame( + panelDay(chip.original.end, tz), + )) && ( +
+ {t("serverOnlineHistory.originalSession")}{" "} + {panelDay(chip.original.start, tz).format( + "MM-DD HH:mm:ss", + )} + {" → "} + {chip.original.ongoing + ? t("serverOnlineHistory.ongoing") + : panelDay( + chip.original.end, + tz, + ).format("MM-DD HH:mm:ss")} +
+ )} +
+
+ ); + })} +
+
+ ))} + {groups.length > visibleDays && ( + + )} +
+
+ ); + })()} +
+
+ + ); +} diff --git a/src/components/ServerOverview.tsx b/src/components/ServerOverview.tsx index 43bdc62..0818409 100644 --- a/src/components/ServerOverview.tsx +++ b/src/components/ServerOverview.tsx @@ -106,7 +106,6 @@ export default function ServerOverview({

- @@ -136,7 +135,6 @@ export default function ServerOverview({

- diff --git a/src/components/loading/OnlineHistorySkeleton.tsx b/src/components/loading/OnlineHistorySkeleton.tsx new file mode 100644 index 0000000..051d42c --- /dev/null +++ b/src/components/loading/OnlineHistorySkeleton.tsx @@ -0,0 +1,138 @@ +import { useTranslation } from "react-i18next"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +function Bar({ className }: { className: string }) { + return ( + + ); +} + +function Metric({ footer = true }: { footer?: boolean }) { + return ( +
+
+ +
+ + {footer && ( +
+ +
+ )} +
+ ); +} + +export default function OnlineHistorySkeleton({ days = 1 }: { days?: number }) { + const { t } = useTranslation(); + const dayCount = Math.min(31, Math.max(1, days) + 1); + const cardClassName = cn({ + "bg-card/70": + typeof window !== "undefined" && Boolean(window.CustomBackgroundImage), + }); + + return ( +
+ +
+ ); +} diff --git a/src/hooks/use-media-query.ts b/src/hooks/use-media-query.ts new file mode 100644 index 0000000..5e6d725 --- /dev/null +++ b/src/hooks/use-media-query.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +// matchMedia 不可用(如 jsdom)时默认命中桌面布局,保证测试环境渲染完整概要 +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return true; + } + return window.matchMedia(query).matches; + }); + + useEffect(() => { + if (typeof window.matchMedia !== "function") { + return; + } + const mediaQueryList = window.matchMedia(query); + const onChange = (event: MediaQueryListEvent) => { + setMatches(event.matches); + }; + setMatches(mediaQueryList.matches); + mediaQueryList.addEventListener("change", onChange); + return () => { + mediaQueryList.removeEventListener("change", onChange); + }; + }, [query]); + + return matches; +} diff --git a/src/i18n.js b/src/i18n.js index 052053f..c9c2d9f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -4,8 +4,15 @@ import { initReactI18next } from "react-i18next"; import deTranslation from "./locales/de/translation.json"; import enTranslation from "./locales/en/translation.json"; import esTranslation from "./locales/es/translation.json"; +import frTranslation from "./locales/fr/translation.json"; +import glTranslation from "./locales/gl/translation.json"; +import idTranslation from "./locales/id/translation.json"; +import jaTranslation from "./locales/ja/translation.json"; +import ptBRTranslation from "./locales/pt_BR/translation.json"; +import roTranslation from "./locales/ro/translation.json"; import ruTranslation from "./locales/ru/translation.json"; import taTranslation from "./locales/ta/translation.json"; +import ukTranslation from "./locales/uk/translation.json"; import zhCNTranslation from "./locales/zh-CN/translation.json"; import zhTWTranslation from "./locales/zh-TW/translation.json"; @@ -25,12 +32,33 @@ const resources = { "es-ES": { translation: esTranslation, }, + fr: { + translation: frTranslation, + }, + gl: { + translation: glTranslation, + }, + id: { + translation: idTranslation, + }, + ja: { + translation: jaTranslation, + }, + pt_BR: { + translation: ptBRTranslation, + }, + ro: { + translation: roTranslation, + }, "ru-RU": { translation: ruTranslation, }, "ta-IN": { translation: taTranslation, }, + uk: { + translation: ukTranslation, + }, }; const getStoredLanguage = () => { diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts index 175d1ce..a85d3ac 100644 --- a/src/lib/nezha-api.ts +++ b/src/lib/nezha-api.ts @@ -3,12 +3,21 @@ import type { MetricPeriod, MetricType, MonitorResponse, + OnlineHistoryResponse, ServerGroupResponse, ServerMetricsResponse, ServiceResponse, SettingResponse, } from "@/types/nezha-api"; +export const API_ERROR_UNAUTHORIZED = "ApiErrorUnauthorized"; + +export function isUnauthorizedError(error: unknown): boolean { + const message = + error instanceof Error ? error.message : error == null ? "" : String(error); + return message === API_ERROR_UNAUTHORIZED; +} + let lastestRefreshTokenAt = 0; const getCookieValue = (name: string): string | undefined => { @@ -110,3 +119,18 @@ export const fetchServerMetrics = async ( } return data; }; + +export const fetchServerOnlineHistory = async ( + server_id: number, + days?: number, +): Promise => { + const query = days ? `?days=${days}` : ""; + const response = await fetch( + `/api/v1/server/${server_id}/online-history${query}`, + ); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + return data; +}; diff --git a/src/lib/online-history.ts b/src/lib/online-history.ts new file mode 100644 index 0000000..b6b2ba7 --- /dev/null +++ b/src/lib/online-history.ts @@ -0,0 +1,398 @@ +import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import type { DailyOnlineStat, OnlinePeriod } from "@/types/nezha-api"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export type PeriodChip = { + start: dayjs.Dayjs; + end: dayjs.Dayjs; + ongoing: boolean; + original?: OnlinePeriod; +}; + +export type PeriodDayGroup = { + date: string; + day: dayjs.Dayjs; + total: number; + chips: PeriodChip[]; +}; + +export type DaySegment = { + period: OnlinePeriod; + segStart: dayjs.Dayjs; + segEnd: dayjs.Dayjs; + leftPct: number; + widthPct: number; + /** 该段是否仍然进行中(只有结束于真实会话末尾的段才为 true) */ + live: boolean; +}; + +// 后端按面板时区拆日;响应带 timezone 时前端用同一时区解释时间, +// 保证跨时区访客的时间轴、分组与每日数字一致 +export function panelDay(value: string, tz?: string): dayjs.Dayjs { + // dayjs 的 .tz(input, zone) 解析模式会忽略输入里的显式偏移(Z / ±HH:MM): + // 带偏移的时间戳须先按原义解析再换算时区,无偏移的日期串才用 zone 解析 + if (tz) { + return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) + ? dayjs(value).tz(tz) + : dayjs.tz(value, tz); + } + return dayjs(value); +} + +export function panelNow(tz?: string): dayjs.Dayjs { + return tz ? dayjs().tz(tz) : dayjs(); +} + +// 下一天的日历日期用 UTC 做纯日期运算,避免 dayjs .add(1, "day") +// 在 DST 切换日加的是 24 小时而不是下一个挂钟零点 +function nextDate(date: string): string { + const [year, month, day] = date.split("-").map(Number); + return new Date(Date.UTC(year, month - 1, day + 1)) + .toISOString() + .slice(0, 10); +} + +export function panelNextDayStart(date: string, tz?: string): dayjs.Dayjs { + return panelDay(nextDate(date), tz); +} + +// 结束于次日零点的段在 chip 上显示为 24:00,避免出现“00:00 → 00:00”这种读不通的全天段 +export function formatPeriodMoment( + value: dayjs.Dayjs, + anchor: dayjs.Dayjs, + tz?: string, + withSeconds = false, +): string { + const dayEnd = panelNextDayStart(anchor.format("YYYY-MM-DD"), tz); + if (value.valueOf() === dayEnd.valueOf()) { + return withSeconds ? "24:00:00" : "24:00"; + } + return value.format(withSeconds ? "HH:mm:ss" : "HH:mm"); +} + +export function formatDuration(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) { + return `${d}d ${h}h ${m}m`; + } + if (h > 0) { + return `${h}h ${m}m`; + } + return `${m}m`; +} + +// 时间轴刻度是固定的挂钟 0:00–24:00,定位必须用面板时区的挂钟时间; +// 用 elapsed seconds / 86400 会在 DST 切换日(23h/25h)偏移 +function wallClockPercent(value: dayjs.Dayjs): number { + const seconds = + value.hour() * 3600 + + value.minute() * 60 + + value.second() + + value.millisecond() / 1000; + return (seconds / 86400) * 100; +} + +// 每天实际纳入统计的区间;挂钟坐标用于绘图,秒数用于时长/在线率。 +export function buildDayWindow( + date: string, + windowStart: string, + windowEnd: string, + tz?: string, +) { + const dayStart = panelDay(date, tz); + const dayEnd = panelNextDayStart(date, tz); + const start = panelDay(windowStart, tz); + const end = panelDay(windowEnd, tz); + const effectiveStart = + start.valueOf() > dayStart.valueOf() ? start : dayStart; + const effectiveEnd = end.valueOf() < dayEnd.valueOf() ? end : dayEnd; + const percent = (value: dayjs.Dayjs) => + value.valueOf() < dayStart.valueOf() || + value.valueOf() === dayStart.valueOf() + ? 0 + : value.valueOf() > dayEnd.valueOf() || + value.valueOf() === dayEnd.valueOf() + ? 100 + : wallClockPercent(value); + return { + dayStart, + dayEnd, + start: effectiveStart, + end: effectiveEnd, + seconds: Math.max(0, effectiveEnd.diff(effectiveStart, "second")), + leftPct: percent(effectiveStart), + rightPct: percent(effectiveEnd), + }; +} + +export function dailyOnlineRate(onlineSeconds: number, windowSeconds: number) { + return windowSeconds > 0 + ? Math.max(0, Math.min(100, (onlineSeconds / windowSeconds) * 100)) + : 0; +} + +// 每份响应只解析一次时段和窗口边界;后续按天裁剪仅比较时间戳。 +function preparePeriods( + periods: OnlinePeriod[], + tz?: string, + windowStart?: string, + windowEnd?: string, +) { + const lower = windowStart ? panelDay(windowStart, tz) : null; + const upper = windowEnd ? panelDay(windowEnd, tz) : null; + return periods.flatMap((period) => { + const originalStart = panelDay(period.start, tz); + const originalEnd = panelDay(period.end, tz); + const start = + lower && lower.valueOf() > originalStart.valueOf() + ? lower + : originalStart; + const end = + upper && upper.valueOf() < originalEnd.valueOf() ? upper : originalEnd; + return start.isValid() && end.isValid() && end.valueOf() > start.valueOf() + ? [ + { + period, + start, + end, + live: period.ongoing && end.valueOf() === originalEnd.valueOf(), + }, + ] + : []; + }); +} + +function buildParsedDaySegments( + periods: ReturnType, + dayStart: dayjs.Dayjs, + dayEnd: dayjs.Dayjs, +): DaySegment[] { + const lower = dayStart.valueOf(), + upper = dayEnd.valueOf(); + return periods.flatMap(({ period, start, end, live }) => { + if (end.valueOf() <= lower || start.valueOf() >= upper) return []; + const segStart = start.valueOf() > lower ? start : dayStart; + const segEnd = end.valueOf() < upper ? end : dayEnd; + const leftPct = + segStart.valueOf() === lower ? 0 : wallClockPercent(segStart); + const rightPct = + segEnd.valueOf() === upper ? 100 : wallClockPercent(segEnd); + const widthPct = Math.max(0, Math.min(rightPct - leftPct, 100 - leftPct)); + return widthPct > 0 + ? [ + { + period, + segStart, + segEnd, + leftPct, + widthPct, + live: live && segEnd.valueOf() === end.valueOf(), + }, + ] + : []; + }); +} + +export function buildDaySegments( + periods: OnlinePeriod[], + date: string, + tz?: string, + windowStart?: string, + windowEnd?: string, +): DaySegment[] { + return buildParsedDaySegments( + preparePeriods(periods, tz, windowStart, windowEnd), + panelDay(date, tz), + panelNextDayStart(date, tz), + ); +} + +// 时间轴、每日总计及在线列表共享这份结果,不再各自遍历并转换整份历史。 +export function buildDailyOnlineRows( + periods: OnlinePeriod[], + daily: DailyOnlineStat[], + windowStart: string, + windowEnd: string, + tz?: string, +) { + const prepared = preparePeriods(periods, tz, windowStart, windowEnd); + return daily.map((day) => { + const window = buildDayWindow(day.date, windowStart, windowEnd, tz); + const segments = buildParsedDaySegments( + prepared, + window.dayStart, + window.dayEnd, + ); + return { + ...day, + segments, + window, + online_seconds: segments.reduce( + (sum, segment) => sum + segment.segEnd.diff(segment.segStart, "second"), + 0, + ), + }; + }); +} + +export function groupOnlineRows( + rows: { date: string; segments: DaySegment[] }[], + tz?: string, +): PeriodDayGroup[] { + return rows + .filter((row) => row.segments.length > 0) + .map((row) => ({ + date: row.date, + day: panelDay(row.date, tz), + chips: row.segments.map((segment) => ({ + start: segment.segStart, + end: segment.segEnd, + ongoing: segment.live, + original: segment.period, + })), + total: row.segments.reduce( + (sum, segment) => sum + segment.segEnd.diff(segment.segStart, "second"), + 0, + ), + })) + .sort((a, b) => b.date.localeCompare(a.date)); +} + +// 合并重叠/相邻的时间段,按开始时间排序 +export function mergePeriods( + periods: OnlinePeriod[], + tz?: string, +): PeriodChip[] { + const sorted = periods + .map((period) => ({ + start: panelDay(period.start, tz), + end: panelDay(period.end, tz), + ongoing: period.ongoing, + })) + .sort((a, b) => a.start.valueOf() - b.start.valueOf()); + const merged: PeriodChip[] = []; + for (const period of sorted) { + const last = merged[merged.length - 1]; + if (last && period.start.valueOf() < last.end.valueOf()) { + if (period.end.valueOf() > last.end.valueOf()) { + last.end = period.end; + } + last.ongoing = last.ongoing || period.ongoing; + } else { + merged.push({ ...period }); + } + } + return merged; +} + +// 独立调用也复用同一预解析/裁剪流程。 +export function groupOnlinePeriods( + periods: OnlinePeriod[], + tz?: string, + windowStart?: string, + windowEnd?: string, +): PeriodDayGroup[] { + const prepared = preparePeriods(periods, tz, windowStart, windowEnd); + const dates = new Set(); + for (const { start, end } of prepared) { + const endDate = end.format("YYYY-MM-DD"); + for ( + let date = start.format("YYYY-MM-DD"); + date <= endDate; + date = nextDate(date) + ) + dates.add(date); + } + return groupOnlineRows( + [...dates].map((date) => ({ + date, + segments: buildParsedDaySegments( + prepared, + panelDay(date, tz), + panelNextDayStart(date, tz), + ), + })), + tz, + ); +} + +// 离线模式:对窗口内每一天求在线时段的补集。计算区间先与查询窗口取交集, +// 避免在窗口开始前/结束后凭空生成离线段;服务器当前离线时,结束于窗口末端的 +// 最新一段标记为进行中。 +export function buildOfflineGroups( + periods: OnlinePeriod[], + daily: DailyOnlineStat[], + windowStart: string, + windowEnd: string, + currentlyOnline: boolean, + tz?: string, +): PeriodDayGroup[] { + const merged = mergePeriods(periods, tz); + const windowStartTime = panelDay(windowStart, tz); + const windowEndTime = panelDay(windowEnd, tz); + const groups = daily + .map((day) => day.date) + .sort((a, b) => (a < b ? 1 : -1)) + .flatMap((date): PeriodDayGroup[] => { + const dayStart = panelDay(date, tz); + const dayEnd = panelNextDayStart(date, tz); + // 当天与查询窗口的交集;首日/末日通常不是完整 24 小时 + const effectiveStart = + dayStart.valueOf() > windowStartTime.valueOf() + ? dayStart + : windowStartTime; + const effectiveEnd = + dayEnd.valueOf() < windowEndTime.valueOf() ? dayEnd : windowEndTime; + if (!(effectiveEnd.valueOf() > effectiveStart.valueOf())) { + return []; + } + const chips: PeriodChip[] = []; + let cursor = effectiveStart; + for (const segment of merged) { + if (!(segment.end.valueOf() > effectiveStart.valueOf())) { + continue; + } + // merged 按开始时间升序;到达当天区间之后的时段即可停止, + // 否则会把未来日期的开始时刻当成当天的 gap 终点 + if (!(segment.start.valueOf() < effectiveEnd.valueOf())) { + break; + } + const segStart = + segment.start.valueOf() > cursor.valueOf() ? segment.start : cursor; + if (segStart.valueOf() > cursor.valueOf()) { + chips.push({ start: cursor, end: segStart, ongoing: false }); + } + const segEnd = + segment.end.valueOf() < effectiveEnd.valueOf() + ? segment.end + : effectiveEnd; + if (segEnd.valueOf() > cursor.valueOf()) { + cursor = segEnd; + } + } + if (cursor.valueOf() < effectiveEnd.valueOf()) { + chips.push({ start: cursor, end: effectiveEnd, ongoing: false }); + } + const total = chips.reduce( + (sum, chip) => sum + chip.end.diff(chip.start, "second"), + 0, + ); + return [{ date, day: dayStart, total, chips }]; + }) + .filter((group) => group.chips.length > 0); + if (!currentlyOnline && groups.length > 0) { + const newest = groups[0].chips; + const last = newest[newest.length - 1]; + // 只有真正持续到窗口末端的离线段才是进行中;旧日期的段不能误标 + if (last?.end.isSame(windowEndTime)) { + last.ongoing = true; + } + } + return groups; +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 2612943..6d792d4 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -89,7 +89,8 @@ }, "tabSwitch": { "Network": "Netzwerk", - "Detail": "Detail" + "Detail": "Detail", + "Online": "Online" }, "nezha": "Nezha Monitoring", "dashboard": "Dashboard", @@ -140,5 +141,23 @@ "up_total": "Upload ges.", "down_total": "Download ges." } + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b31c1de..859e935 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -98,7 +98,14 @@ "de-DE": "Deutsch", "es-ES": "Español", "ru-RU": "Русский", - "ta-IN": "தமிழ்" + "ta-IN": "தமிழ்", + "fr": "Français", + "gl": "Galego", + "id": "Bahasa Indonesia", + "ja": "日本語", + "pt_BR": "Português (Brasil)", + "ro": "Română", + "uk": "Українська" }, "theme": { "light": "Light", @@ -110,11 +117,14 @@ "pageNotFound": "Page not found", "backToHome": "Back to home", "backendUnavailableTitle": "Backend API unavailable", - "backendUnavailableDescription": "The page is still available, but data cannot be loaded right now." + "backendUnavailableDescription": "The page is still available, but data cannot be loaded right now.", + "loginRequiredTitle": "Login required", + "loginRequiredDescription": "This site requires a login to load monitoring data." }, "tabSwitch": { "Detail": "Detail", - "Network": "Network" + "Network": "Network", + "Online": "Online" }, "monitor": { "noData": "No server monitor data, please add a service monitor first", @@ -176,5 +186,30 @@ }, "group": { "all": "All" + }, + "serverOnlineHistory": { + "loading": "Loading online history", + "originalSession": "Original session", + "dataCutoffHint": "Marker: data cutoff", + "dataCutoff": "Data through", + "notYetRecorded": "Not yet recorded", + "outsideWindow": "Outside selected window", + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Total online", + "totalOffline": "Total offline", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index cd9eebc..8fbc77c 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -86,7 +86,8 @@ }, "tabSwitch": { "Detail": "Detalle", - "Network": "Red" + "Network": "Red", + "Online": "Online" }, "monitor": { "avgDelay": "Latencia", @@ -155,5 +156,23 @@ "up_total": "Subida total", "down_total": "Bajada total" } + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 855ae21..9531861 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -12,5 +12,26 @@ }, "serverDetail": { "download": "Téléchargement" + }, + "tabSwitch": { + "Online": "Online" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/gl/translation.json b/src/locales/gl/translation.json index 3de11df..86d21ee 100644 --- a/src/locales/gl/translation.json +++ b/src/locales/gl/translation.json @@ -102,7 +102,8 @@ }, "tabSwitch": { "Detail": "Detalle", - "Network": "Rede" + "Network": "Rede", + "Online": "Online" }, "monitor": { "noData": "Non hai datos de monitor de servidor. Engada primeiro un monitor de servizo", @@ -133,5 +134,23 @@ "ToggleLightMode": "Cambiar ao modo claro", "ToggleDarkMode": "Cambiar ao modo escuro", "ToggleSystemMode": "Cambiar ao determinado polo sistema", - "Home": "Páxina principal" + "Home": "Páxina principal", + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" + } } diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index c7cfda6..ec3165a 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -108,11 +108,14 @@ "pageNotFound": "Halaman tidak ditemukan", "backToHome": "Kembali ke beranda", "backendUnavailableTitle": "API backend tidak tersedia", - "backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini." + "backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini.", + "loginRequiredTitle": "Perlu masuk", + "loginRequiredDescription": "Situs ini memerlukan login untuk memuat data pemantauan." }, "tabSwitch": { "Detail": "Detail", - "Network": "Jaringan" + "Network": "Jaringan", + "Online": "Online" }, "monitor": { "noData": "Tidak ada data pemantauan server, harap tambahkan pemantauan layanan terlebih dahulu", @@ -167,5 +170,23 @@ }, "group": { "all": "Semua" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index db7758c..9813326 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -23,5 +23,26 @@ "map": { "Distributions": "サーバーの場所", "Regions": "地域" + }, + "tabSwitch": { + "Online": "Online" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/pt_BR/translation.json b/src/locales/pt_BR/translation.json index 9368fd5..8537c75 100644 --- a/src/locales/pt_BR/translation.json +++ b/src/locales/pt_BR/translation.json @@ -108,11 +108,14 @@ "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." + "backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora.", + "loginRequiredTitle": "Login necessário", + "loginRequiredDescription": "Este site exige login para carregar os dados de monitoramento." }, "tabSwitch": { "Detail": "Detalhes", - "Network": "Rede" + "Network": "Rede", + "Online": "Online" }, "monitor": { "noData": "Sem servidores para monitorar. por favor adicione um primeiro monitor de serviço", @@ -163,5 +166,23 @@ }, "group": { "all": "Todos" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index 9638c12..1e78388 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -4,5 +4,26 @@ "light": "Mod luminos", "dark": "Mod întunecat" }, - "Servers": "Servere" + "Servers": "Servere", + "tabSwitch": { + "Online": "Online" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" + } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 4d8d247..0365a39 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -30,7 +30,8 @@ }, "tabSwitch": { "Detail": "Детали", - "Network": "Сеть" + "Network": "Сеть", + "Online": "Online" }, "whereTheTimeIs": "Где время", "theme": { @@ -159,5 +160,23 @@ "up_total": "Загружено", "down_total": "Скачано" } + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/ta/translation.json b/src/locales/ta/translation.json index 5378064..1085a1f 100644 --- a/src/locales/ta/translation.json +++ b/src/locales/ta/translation.json @@ -90,7 +90,8 @@ }, "tabSwitch": { "Detail": "விவரம்", - "Network": "பிணையம்" + "Network": "பிணையம்", + "Online": "Online" }, "monitor": { "noData": "சேவையக மானிட்டர் தரவு இல்லை, முதலில் ஒரு பணி மானிட்டரைச் சேர்க்கவும்", @@ -140,5 +141,23 @@ "up_total": "மொத்த பதிவேற்றம்", "down_total": "மொத்த பதிவிறக்கம்" } + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index be0878f..668a903 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -69,5 +69,26 @@ "today": "Сьогодні", "loading": "Завантаження..." }, - "login": "Логін" + "login": "Логін", + "tabSwitch": { + "Online": "Online" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" + } } diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index c346068..0d1e5c0 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -111,11 +111,14 @@ "pageNotFound": "页面不存在", "backToHome": "回到主页", "backendUnavailableTitle": "后端 API 无法访问", - "backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。" + "backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。", + "loginRequiredTitle": "需要登录", + "loginRequiredDescription": "当前站点已开启强制登录,登录后即可加载监控数据。" }, "tabSwitch": { "Detail": "详情", - "Network": "网络" + "Network": "网络", + "Online": "在线" }, "monitor": { "noData": "没有服务监控数据,请在管理后台服务页添加监控任务", @@ -177,5 +180,30 @@ }, "group": { "all": "全部" + }, + "serverOnlineHistory": { + "loading": "正在加载在线记录", + "originalSession": "原始会话", + "dataCutoffHint": "竖线:数据截止", + "dataCutoff": "数据截止", + "notYetRecorded": "尚未统计", + "outsideWindow": "未纳入统计", + "days1": "1 天", + "days7": "7 天", + "days30": "30 天", + "onlineRate": "在线率", + "totalOnline": "累计在线", + "totalOffline": "累计离线", + "lastActive": "最后活跃", + "dailyOnline": "每日在线时长", + "periods": "在线时间段", + "ongoing": "进行中", + "noData": "暂无在线记录", + "loginRequired": "当前窗口需要登录后查看,游客可切换到 1 天", + "offlinePeriods": "不在线时间段", + "periodsOnline": "在线", + "periodsOffline": "离线", + "expandMore": "展开更多", + "today": "今天" } } diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 7ff93d9..598607a 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -2,11 +2,11 @@ "nezha": "哪吒監控", "overview": "概覽", "dashboard": "管理後台", - "login": "登錄", - "online": "在線", + "login": "登入", + "online": "線上", "offline": "離線", "whereTheTimeIs": "目前時間", - "refreshing": "刷新中", + "refreshing": "重新整理中", "info": { "websocketConnecting": "WebSocket 連線中", "websocketConnected": "WebSocket 連線成功", @@ -19,7 +19,7 @@ "totalServers": "總伺服器", "onlineServers": "線上伺服器", "offlineServers": "離線伺服器", - "totalBandwidth": "總帶寬", + "totalBandwidth": "總頻寬", "speed": "速率", "network": "網路", "totalDomains": "總域名數" @@ -30,14 +30,14 @@ "Servers": "個伺服器" }, "serverCard": { - "mem": "內存", - "stg": "存儲", + "mem": "記憶體", + "stg": "儲存空間", "days": "天", "hours": "小時", "upload": "上傳", "download": "下載", "system": "系統", - "uptime": "運行時間", + "uptime": "運作時間", "totalUpload": "總上傳", "totalDownload": "總下載" }, @@ -47,8 +47,8 @@ "nextUpdate": "下次更新" }, "serviceTracker": { - "noService": "無服務數據", - "uptime": "在線率", + "noService": "無服務資料", + "uptime": "上線率", "delay": "延遲", "daysAgo": "天前", "today": "今天", @@ -61,26 +61,32 @@ "hours": "小時", "offline": "離線", "unknown": "未知", - "uptime": "運行時間", + "uptime": "運作時間", "version": "版本", "arch": "架構", - "mem": "內存", - "disk": "磁盤", + "mem": "記憶體", + "disk": "磁碟", "region": "地區", "system": "系統", "upload": "上傳", "download": "下載", - "lastActive": "最後上報時間", + "lastActive": "最後回報時間", "temperature": "溫度", "bootTime": "啟動時間" }, "serverDetailChart": { - "process": "進程數", - "disk": "磁盤", - "mem": "內存", + "process": "程序數", + "disk": "磁碟", + "mem": "記憶體", "swap": "虛擬記憶體", "upload": "上傳", - "download": "下載" + "download": "下載", + "realtime": "即時", + "period1d": "1 天", + "period7d": "7 天", + "period30d": "30 天", + "tsdbRequired": "請啟用 TSDB 以使用歷史資料", + "loginRequired": "請先登入才能查看" }, "footer": { "themeBy": "主題-" @@ -102,7 +108,7 @@ }, "error": { "pageNotFound": "頁面不存在", - "backToHome": "回到主頁", + "backToHome": "回到首頁", "backendUnavailableTitle": "後端 API 無法存取", "backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。" }, @@ -110,23 +116,25 @@ "detail": "詳細資訊", "network": "網路", "Detail": "詳細資訊", - "Network": "網路" + "Network": "網路", + "Online": "線上" }, "monitor": { - "noData": "沒有服務監控數據,請在管理後台服務新增監控任務", + "noData": "沒有伺服器監控資料,請先新增服務監控", "status": "狀態", "avgDelay": "延遲", "monitorCount": "個監控", - "packetLoss": "丟包率", + "packetLoss": "封包遺失率", "clearSelections": "清除", "peakCut": "削峰", "period1d": "1 天", "period7d": "7 天", - "period30d": "30 天" + "period30d": "30 天", + "loginRequired": "請先登入才能查看" }, "billingInfo": { "remaining": "剩餘天數", - "error": "獲取失敗", + "error": "取得失敗", "indefinite": "無限期", "expired": "已過期", "days": "天", @@ -151,7 +159,7 @@ }, "pwa": { "offlineReady": "可離線使用之應用程式", - "newContent": "有新内容可用", + "newContent": "有新內容可用", "reload": "更新" }, "sort": { @@ -159,7 +167,7 @@ "types": { "default": "預設", "name": "名稱", - "uptime": "運行時間", + "uptime": "運作時間", "system": "系統", "cpu": "CPU", "mem": "記憶體", @@ -172,5 +180,30 @@ }, "group": { "all": "全部" + }, + "serverOnlineHistory": { + "loading": "正在載入在線記錄", + "originalSession": "原始連線", + "dataCutoffHint": "直線:資料截止", + "dataCutoff": "資料截止", + "notYetRecorded": "尚未統計", + "outsideWindow": "未納入統計", + "days1": "1 天", + "days7": "7 天", + "days30": "30 天", + "onlineRate": "線上率", + "totalOnline": "累計在線", + "totalOffline": "累計離線", + "lastActive": "最後活躍", + "dailyOnline": "每日線上時長", + "periods": "線上時間段", + "ongoing": "進行中", + "noData": "暫無線上紀錄", + "loginRequired": "此視窗需要登入後查看,訪客可切換到 1 天", + "offlinePeriods": "Offline periods", + "periodsOnline": "在線", + "periodsOffline": "離線", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/main.tsx b/src/main.tsx index c05cbd0..cebb62d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,6 +14,10 @@ import { WebSocketProvider } from "./context/websocket-provider"; import { initCustomConfig } from "./lib/custom-config"; import "./i18n"; import "./index.css"; +// Bundled instead of loaded from jsdelivr: the CDN in index.html was +// render-blocking and unreliable on mainland networks. Vite hashes the flag +// sprites into assets/, so they inherit the immutable cache headers. +import "flag-icons/css/flag-icons.min.css"; initCustomConfig(); diff --git a/src/pages/Server.tsx b/src/pages/Server.tsx index 09f16d1..2a8d00a 100644 --- a/src/pages/Server.tsx +++ b/src/pages/Server.tsx @@ -19,11 +19,16 @@ import ServerCard from "@/components/ServerCard"; import ServerCardInline from "@/components/ServerCardInline"; import ServerOverview from "@/components/ServerOverview"; import { ServiceTracker } from "@/components/ServiceTracker"; +import { Button } from "@/components/ui/button"; 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, fetchService } from "@/lib/nezha-api"; +import { + fetchServerGroup, + fetchService, + isUnauthorizedError, +} from "@/lib/nezha-api"; import { cn } from "@/lib/utils"; import type { NezhaServer, ServerGroup } from "@/types/nezha-api"; @@ -55,21 +60,36 @@ const getErrorMessage = (error: unknown) => { function BackendErrorState({ error }: { error: unknown }) { const { t } = useTranslation(); + const unauthorized = isUnauthorizedError(error); const message = getErrorMessage(error); return (

- {t("error.backendUnavailableTitle")} + {t( + unauthorized + ? "error.loginRequiredTitle" + : "error.backendUnavailableTitle", + )}

- {t("error.backendUnavailableDescription")} + {t( + unauthorized + ? "error.loginRequiredDescription" + : "error.backendUnavailableDescription", + )}

- {message && ( -

- {message} -

+ {unauthorized ? ( + + ) : ( + message && ( +

+ {message} +

+ ) )}
diff --git a/src/pages/ServerDetail.tsx b/src/pages/ServerDetail.tsx index dac573d..1b81de2 100644 --- a/src/pages/ServerDetail.tsx +++ b/src/pages/ServerDetail.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { Navigate, useParams } from "react-router-dom"; +import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton"; import NetworkChartLoading from "@/components/NetworkChartLoading"; import ServerDetailChart from "@/components/ServerDetailChart"; import ServerDetailOverview from "@/components/ServerDetailOverview"; @@ -12,12 +13,18 @@ const NetworkChart = lazy(() => })), ); +const ServerDetailOnlineHistory = lazy(() => + import("@/components/ServerDetailOnlineHistory").then((module) => ({ + default: module.default, + })), +); + export default function ServerDetail() { useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: "instant" }); }, []); - const tabs = ["Detail", "Network"]; + const tabs = ["Detail", "Network", "Online"]; const [currentTab, setCurrentTab] = useState(tabs[0]); const { id: server_id } = useParams(); @@ -51,6 +58,11 @@ export default function ServerDetail() { )} + {currentTab === tabs[2] && ( + }> + + + )}
); } diff --git a/src/test/components/server-detail-online-history.test.tsx b/src/test/components/server-detail-online-history.test.tsx new file mode 100644 index 0000000..2eef023 --- /dev/null +++ b/src/test/components/server-detail-online-history.test.tsx @@ -0,0 +1,450 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import dayjs from "dayjs"; +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ServerDetailOnlineHistory from "@/components/ServerDetailOnlineHistory"; +import { createTestQueryClient } from "@/test/utils"; +import type { OnlineHistoryResponse } from "@/types/nezha-api"; + +const websocketMocks = vi.hoisted(() => ({ + lastData: null as { servers: { id: number; last_active: string }[] } | null, +})); +vi.mock("@/hooks/use-websocket-context", () => ({ + useWebSocketContext: () => websocketMocks, +})); + +const apiMocks = vi.hoisted(() => ({ + fetchServerOnlineHistory: vi.fn(), +})); + +vi.mock("@/lib/nezha-api", () => ({ + fetchServerOnlineHistory: apiMocks.fetchServerOnlineHistory, +})); + +function onlineHistoryResponse(maxDays = 30): OnlineHistoryResponse { + return { + success: true, + data: { + server_id: 7, + server_name: "edge-online", + days: 7, + max_days: maxDays, + timezone: "UTC", + window_start: "2026-10-07T04:00:00.000Z", + window_end: "2026-10-14T12:00:00.000Z", + total_online_seconds: 5400, + online_rate: 0.89, + currently_online: true, + last_active: "2026-10-14T03:30:00.000Z", + periods: [ + { + start: "2026-10-13T08:00:00.000Z", + end: "2026-10-13T09:30:00.000Z", + ongoing: false, + }, + { + start: "2026-10-14T10:00:00.000Z", + end: "2026-10-14T11:00:00.000Z", + ongoing: true, + }, + ], + daily: [ + { date: "2026-10-13", online_seconds: 5400 }, + { date: "2026-10-14", online_seconds: 3600 }, + ], + }, + }; +} + +function renderWithQuery(ui: ReactElement) { + return render( + + {ui} + , + ); +} + +describe("ServerDetailOnlineHistory", () => { + beforeEach(() => { + websocketMocks.lastData = null; + apiMocks.fetchServerOnlineHistory.mockReset(); + }); + + it("renders the loading state while history data is unavailable", () => { + apiMocks.fetchServerOnlineHistory.mockReturnValue( + new Promise(() => undefined), + ); + + renderWithQuery(); + expect( + screen.getByRole("status", { name: "serverOnlineHistory.loading" }), + ).toHaveAttribute("aria-busy", "true"); + expect(screen.getByTestId("online-history-loading")).toBeInTheDocument(); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 1); + }); + + it("keeps existing content instead of flashing the skeleton while changing days", async () => { + apiMocks.fetchServerOnlineHistory + .mockResolvedValueOnce(onlineHistoryResponse()) + .mockReturnValue(new Promise(() => undefined)); + renderWithQuery(); + await screen.findByText("0.9%"); + expect( + screen.queryByTestId("online-history-loading"), + ).not.toBeInTheDocument(); + await userEvent + .setup() + .click(screen.getByText("serverOnlineHistory.days30")); + await waitFor(() => + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30), + ); + expect(screen.getByText("0.9%")).toBeInTheDocument(); + expect( + screen.queryByTestId("online-history-loading"), + ).not.toBeInTheDocument(); + }); + + it("renders daily totals and all groups when fewer than three days are available", async () => { + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(), + ); + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + expect(screen.getAllByText("online")).toHaveLength(3); + expect(screen.getByText("0.9%")).toBeInTheDocument(); + expect(screen.getAllByText("1h 30m")).toHaveLength(4); + expect(screen.getAllByText("1h 0m")).toHaveLength(3); + expect(screen.getByText("10-13")).toBeInTheDocument(); + expect( + screen.queryByText("serverOnlineHistory.expandMore"), + ).not.toBeInTheDocument(); + expect(screen.getAllByTestId("period-chip")).toHaveLength(2); + }); + + it.each([ + [1800, "1h 30m"], + [7200, "0m"], + [7500, "0m"], + ])("calculates offline time from the actual window with %i online seconds", async (seconds, expected) => { + const response = onlineHistoryResponse(); + response.data.window_start = "2026-10-14T10:00:00.000Z"; + response.data.window_end = "2026-10-14T12:00:00.000Z"; + response.data.total_online_seconds = seconds; + apiMocks.fetchServerOnlineHistory.mockResolvedValue(response); + + renderWithQuery(); + + const label = await screen.findByText("serverOnlineHistory.totalOffline"); + expect(label.parentElement).toHaveTextContent(expected); + }); + + it("maps each day's periods onto the shared 0:00-24:00 timeline", async () => { + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(), + ); + + renderWithQuery(); + + expect(await screen.findByText(/0:00 – 24:00/)).toBeInTheDocument(); + + // 时间轴按面板时区(UTC)的挂钟定位:08:00 在 33.33%,1.5h 宽 6.25% + const segments = await screen.findAllByTestId("online-segment"); + expect(segments).toHaveLength(2); + expect(Number.parseFloat(segments[0].style.left)).toBeCloseTo( + (8 / 24) * 100, + 2, + ); + expect(Number.parseFloat(segments[0].style.width)).toBeCloseTo( + (1.5 / 24) * 100, + 2, + ); + expect(segments[0].style.minWidth).toBe("2px"); + }); + + it("keeps daily totals, carry-over chips and window markers consistent", async () => { + const response = onlineHistoryResponse(); + Object.assign(response.data, { + window_start: "2026-10-13T06:00:00.000Z", + window_end: "2026-10-14T12:00:00.000Z", + total_online_seconds: 9 * 3600, + daily: [ + { date: "2026-10-13", online_seconds: 7200 }, + { date: "2026-10-14", online_seconds: 7 * 3600 }, + ], + periods: [ + { + start: "2026-10-13T22:00:00.000Z", + end: "2026-10-14T02:00:00.000Z", + ongoing: false, + }, + { + start: "2026-10-14T03:00:00.000Z", + end: "2026-10-14T04:00:00.000Z", + ongoing: false, + }, + { + start: "2026-10-14T08:00:00.000Z", + end: "2026-10-14T12:00:00.000Z", + ongoing: true, + }, + ], + }); + apiMocks.fetchServerOnlineHistory.mockResolvedValue(response); + renderWithQuery(); + const rows = await screen.findAllByTestId("daily-online-day"); + expect(within(rows[1]).getByText("7h 0m")).toBeInTheDocument(); + expect(within(rows[1]).getAllByTestId("online-segment")).toHaveLength(3); + const latestGroup = screen.getAllByTestId("period-day")[0]; + expect(within(latestGroup).getByText("7h 0m")).toBeInTheDocument(); + const chips = within(latestGroup).getAllByTestId("period-chip"); + expect(chips).toHaveLength(3); + expect(chips[0]).toHaveTextContent("00:00 → 02:00"); + expect(screen.getByTestId("outside-window")).toHaveStyle({ width: "25%" }); + expect(screen.getByTestId("unrecorded-window")).toHaveStyle({ + left: "50%", + width: "50%", + }); + expect(screen.getByTestId("data-cutoff")).toHaveAccessibleName( + "serverOnlineHistory.dataCutoff 12:00:00", + ); + await userEvent.setup().hover(chips[0]); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "serverOnlineHistory.originalSession 10-13 22:00:00 → 10-14 02:00:00", + ); + }); + + it("renders the compact mobile summary below the md breakpoint", async () => { + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(), + ); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + try { + renderWithQuery(); + + expect(await screen.findByTestId("summary-mobile")).toBeInTheDocument(); + expect(screen.queryByTestId("summary-desktop")).not.toBeInTheDocument(); + // 移动端:在线率 + 在线/离线双指标 + 最近活跃。 + expect(screen.getByText("0.9%")).toBeInTheDocument(); + expect( + screen.getByText("serverOnlineHistory.totalOffline"), + ).toBeInTheDocument(); + expect( + screen.getByText("serverOnlineHistory.totalOnline"), + ).toBeInTheDocument(); + expect( + screen.getByText("serverOnlineHistory.lastActive"), + ).toBeInTheDocument(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps the live last-active time when switching history windows", async () => { + const user = userEvent.setup(); + websocketMocks.lastData = { + servers: [{ id: 7, last_active: "2026-10-14T12:34:56.000Z" }], + }; + apiMocks.fetchServerOnlineHistory.mockImplementation((_id, days) => { + const response = onlineHistoryResponse(); + response.data.online_rate = days === 30 ? 50 : 90; + response.data.last_active = + days === 30 ? "2026-10-14T10:00:00.000Z" : "2026-10-14T11:00:00.000Z"; + return Promise.resolve(response); + }); + renderWithQuery(); + expect(await screen.findByText("12:34:56")).toBeInTheDocument(); + await user.click(screen.getByText("serverOnlineHistory.days30")); + expect(await screen.findByText("50.0%")).toBeInTheDocument(); + await waitFor(() => + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30), + ); + expect(screen.getByText("12:34:56")).toBeInTheDocument(); + await user.click(screen.getByText("serverOnlineHistory.days7")); + expect(screen.getByText("12:34:56")).toBeInTheDocument(); + }); + + it("switches the query window via the day selector", async () => { + const user = userEvent.setup(); + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(), + ); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + await user.click(screen.getByText("serverOnlineHistory.days30")); + + await waitFor(() => { + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30); + }); + }); + + it("locks day options beyond the configured max window", async () => { + const user = userEvent.setup(); + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(7), + ); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + await user.click(screen.getByText("serverOnlineHistory.days30")); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledTimes(1); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 1); + }); + + it("shows the login hint when the API rejects anonymous guests", async () => { + apiMocks.fetchServerOnlineHistory.mockRejectedValue( + new Error("unauthorized: only 1d data available for guests"), + ); + + renderWithQuery(); + + expect( + await screen.findByText("serverOnlineHistory.loginRequired"), + ).toBeInTheDocument(); + }); + + it("falls back to one day when guests request a longer window", async () => { + const user = userEvent.setup(); + apiMocks.fetchServerOnlineHistory + .mockResolvedValueOnce(onlineHistoryResponse()) + .mockRejectedValueOnce( + new Error("unauthorized: only 1d data available for guests"), + ) + .mockResolvedValue(onlineHistoryResponse(1)); + + renderWithQuery(); + + // 首屏直接请求 1d;手动选择 7d 被拒后自动回退到 1d。 + await screen.findByText("serverOnlineHistory.dailyOnline"); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(1, 7, 1); + await user.click(screen.getByText("serverOnlineHistory.days7")); + await waitFor(() => { + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith( + 3, + 7, + 1, + ); + }); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(2, 7, 7); + + // 更长窗口被锁定(游客上限 1 天),点击不再发起新请求 + const calls = apiMocks.fetchServerOnlineHistory.mock.calls.length; + await user.click(screen.getByText("serverOnlineHistory.days30")); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(3, 7, 1); + expect(apiMocks.fetchServerOnlineHistory.mock.calls.length).toBe(calls); + }); + + it("groups periods by the panel timezone from the response", async () => { + const data = onlineHistoryResponse().data; + // 面板时区非 UTC 才能暴露 dayjs .tz(input, zone) 忽略显式偏移的问题 + data.timezone = "Asia/Shanghai"; + data.window_start = "2026-01-01T16:00:00.000Z"; + data.window_end = "2026-01-02T16:00:00.000Z"; + data.daily = [{ date: "2026-01-02", online_seconds: 7200 }]; + data.periods = [ + { + start: "2026-01-01T20:00:00.000Z", + end: "2026-01-01T22:00:00.000Z", + ongoing: false, + }, + ]; + apiMocks.fetchServerOnlineHistory.mockResolvedValue({ + success: true, + data, + }); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + // 20:00Z 在面板时区(UTC+8)为 01-02 04:00:分组落到 01-02,段内时刻同步换算 + expect(screen.getByText("01-02")).toBeInTheDocument(); + expect(screen.queryByText("01-01")).not.toBeInTheDocument(); + const chip = screen.getByTestId("period-chip"); + expect(chip.textContent).toContain("04:00"); + expect(chip.textContent).toContain("06:00"); + }); + + it("shows three days initially and expands ten days per click", async () => { + const user = userEvent.setup(); + const data = onlineHistoryResponse().data; + const dates = Array.from({ length: 25 }, (_, i) => + dayjs("2026-10-14").subtract(i, "day").format("YYYY-MM-DD"), + ); + data.window_start = `${dates[dates.length - 1]}T00:00:00.000Z`; + data.daily = dates.map((date) => ({ date, online_seconds: 3600 })); + data.periods = dates.map((date) => ({ + start: `${date}T06:00:00.000Z`, + end: `${date}T07:00:00.000Z`, + ongoing: false, + })); + apiMocks.fetchServerOnlineHistory.mockResolvedValue({ + success: true, + data, + }); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + expect(screen.getAllByTestId("period-day")).toHaveLength(3); + + await user.click(screen.getByText("serverOnlineHistory.expandMore")); + expect(screen.getAllByTestId("period-day")).toHaveLength(13); + + await user.click(screen.getByText("serverOnlineHistory.expandMore")); + expect(screen.getAllByTestId("period-day")).toHaveLength(23); + + await user.click(screen.getByText("serverOnlineHistory.expandMore")); + expect(screen.getAllByTestId("period-day")).toHaveLength(25); + expect( + screen.queryByText("serverOnlineHistory.expandMore"), + ).not.toBeInTheDocument(); + }); + + it("derives offline periods as the complement of online sessions", async () => { + const user = userEvent.setup(); + const data = onlineHistoryResponse().data; + data.window_start = "2026-10-14T00:00:00.000Z"; + data.window_end = "2026-10-14T12:00:00.000Z"; + data.currently_online = false; + data.daily = [{ date: "2026-10-14", online_seconds: 7200 }]; + data.periods = [ + { + start: "2026-10-14T02:00:00.000Z", + end: "2026-10-14T04:00:00.000Z", + ongoing: false, + }, + ]; + apiMocks.fetchServerOnlineHistory.mockResolvedValue({ + success: true, + data, + }); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + await user.click(screen.getByText("serverOnlineHistory.periodsOffline")); + + // 在线 02:00-04:00 两侧的空档各成一段,且服务器当前离线 → 末段进行中 + const chips = screen.getAllByTestId("period-chip"); + expect(chips).toHaveLength(2); + expect(chips[0].textContent).toContain("00:00"); + expect(chips[0].textContent).toContain("2h 0m"); + expect(chips[1].textContent).toContain("04:00"); + expect(chips[1].textContent).toContain("8h 0m"); + expect(chips[1].textContent).toContain("serverOnlineHistory.ongoing"); + + // 组头合计 = 窗口时长 12h − 当日在线 2h = 10h + expect(screen.getByText("10h 0m")).toBeInTheDocument(); + }); +}); diff --git a/src/test/lib/nezha-api.test.ts b/src/test/lib/nezha-api.test.ts index 6e17fe3..6e0792d 100644 --- a/src/test/lib/nezha-api.test.ts +++ b/src/test/lib/nezha-api.test.ts @@ -6,6 +6,7 @@ import { fetchServerMetrics, fetchService, fetchSetting, + isUnauthorizedError, } from "@/lib/nezha-api"; const jsonResponse = (body: unknown, init?: ResponseInit) => @@ -52,6 +53,12 @@ describe("nezha api fetchers", () => { await expect(fetchService()).rejects.toThrow("service unavailable"); }); + it("detects guest-auth errors from the dashboard API", () => { + expect(isUnauthorizedError(new Error("ApiErrorUnauthorized"))).toBe(true); + expect(isUnauthorizedError(new Error("settings failed"))).toBe(false); + expect(isUnauthorizedError(null)).toBe(false); + }); + it("adds monitor and metrics query parameters when periods are provided", async () => { vi.mocked(fetch) .mockResolvedValueOnce(jsonResponse({ success: true, data: [] })) diff --git a/src/test/lib/online-history.test.ts b/src/test/lib/online-history.test.ts new file mode 100644 index 0000000..a0763a8 --- /dev/null +++ b/src/test/lib/online-history.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; +import { + buildDaySegments, + buildDayWindow, + buildOfflineGroups, + dailyOnlineRate, + formatPeriodMoment, + groupOnlinePeriods, + panelDay, + panelNextDayStart, +} from "@/lib/online-history"; +import type { DailyOnlineStat, OnlinePeriod } from "@/types/nezha-api"; + +function daily(date: string, onlineSeconds: number): DailyOnlineStat { + return { date, online_seconds: onlineSeconds }; +} + +function period(start: string, end: string, ongoing = false): OnlinePeriod { + return { start, end, ongoing }; +} + +function expectChipsInsideTheirDay( + groups: ReturnType, +) { + for (const group of groups) { + const dayStart = panelDay(group.date, "UTC"); + const dayEnd = panelNextDayStart(group.date, "UTC"); + for (const chip of group.chips) { + expect(chip.start.valueOf()).toBeGreaterThanOrEqual(dayStart.valueOf()); + expect(chip.end.valueOf()).toBeLessThanOrEqual(dayEnd.valueOf()); + expect(chip.end.valueOf()).toBeGreaterThan(chip.start.valueOf()); + } + for (let index = 1; index < group.chips.length; index += 1) { + expect(group.chips[index].start.valueOf()).toBeGreaterThanOrEqual( + group.chips[index - 1].end.valueOf(), + ); + } + } +} + +describe("buildOfflineGroups", () => { + it("never lets a gap run into a later day's online segment", () => { + const groups = buildOfflineGroups( + [ + period("2026-10-02T10:00:00.000Z", "2026-10-02T12:00:00.000Z"), + period("2026-10-03T10:00:00.000Z", "2026-10-03T12:00:00.000Z"), + ], + [ + daily("2026-10-01", 0), + daily("2026-10-02", 7200), + daily("2026-10-03", 7200), + ], + "2026-10-01T00:00:00.000Z", + "2026-10-04T00:00:00.000Z", + true, + "UTC", + ); + + expect(groups).toHaveLength(3); + expectChipsInsideTheirDay(groups); + + // 10-01 没有在线段:应是一整段 00:00–24:00,而不是跨到 10-02 10:00 + const oldest = groups[2]; + expect(oldest.date).toBe("2026-10-01"); + expect(oldest.chips).toHaveLength(1); + expect(oldest.chips[0].start.format("HH:mm")).toBe("00:00"); + expect(oldest.chips[0].end.format("HH:mm")).toBe("00:00"); + expect(oldest.chips[0].end.diff(oldest.chips[0].start, "hour")).toBe(24); + expect(oldest.total).toBe(86400); + + // 10-02 / 10-03 各自只有 00:00–10:00 与 12:00–24:00 两段 + for (const group of [groups[1], groups[0]]) { + expect(group.chips).toHaveLength(2); + expect(group.total).toBe(86400 - 7200); + } + }); + + it("starts the first day at window_start and ends the last day at window_end", () => { + const groups = buildOfflineGroups( + [period("2026-10-02T07:00:00.000Z", "2026-10-02T08:00:00.000Z")], + [daily("2026-10-02", 3600), daily("2026-10-03", 0)], + "2026-10-02T06:00:00.000Z", + "2026-10-03T12:00:00.000Z", + false, + "UTC", + ); + + expectChipsInsideTheirDay(groups); + + const first = groups[1]; + expect(first.date).toBe("2026-10-02"); + // 窗口从 06:00 开始,不能凭空生成 00:00–06:00 的离线 + expect(first.chips[0].start.format("HH:mm")).toBe("06:00"); + expect(first.chips[0].end.format("HH:mm")).toBe("07:00"); + // 18h 窗口 − 1h 在线 = 17h + expect(first.total).toBe(17 * 3600); + + const last = groups[0]; + expect(last.date).toBe("2026-10-03"); + expect(last.chips).toHaveLength(1); + expect(last.chips[0].start.format("HH:mm")).toBe("00:00"); + expect(last.chips[0].end.format("HH:mm")).toBe("12:00"); + expect(last.total).toBe(12 * 3600); + // 服务器当前离线,且末段确实结束于窗口末端 → 标记进行中 + expect(last.chips[0].ongoing).toBe(true); + }); + + it("does not mark an old day's last gap as ongoing", () => { + const groups = buildOfflineGroups( + [], + [daily("2026-10-02", 0)], + "2026-10-02T00:00:00.000Z", + "2026-10-02T12:00:00.000Z", + false, + "UTC", + ); + + // 窗口在当天中午结束,末段确实持续到窗口末端 + expect(groups[0].chips[groups[0].chips.length - 1]?.ongoing).toBe(true); + }); +}); + +describe("groupOnlinePeriods", () => { + it("splits cross-midnight sessions into the same segments as the daily timeline", () => { + const periods = [ + period("2026-10-19T22:00:00.000Z", "2026-10-20T03:00:00.000Z", true), + ]; + const groups = groupOnlinePeriods(periods, "UTC"); + expect(groups.map((g) => [g.date, g.total])).toEqual([ + ["2026-10-20", 10800], + ["2026-10-19", 7200], + ]); + for (const group of groups) { + const segments = buildDaySegments(periods, group.date, "UTC"); + expect(group.total).toBe( + segments.reduce( + (sum, s) => sum + s.segEnd.diff(s.segStart, "second"), + 0, + ), + ); + expect(group.chips[0].original).toEqual(periods[0]); + } + expect(groups[0].chips[0].ongoing).toBe(true); + expect(groups[1].chips[0].ongoing).toBe(false); + }); + + it("clips both timeline and chips to the actual query window", () => { + const periods = [ + period("2026-10-19T22:00:00.000Z", "2026-10-21T03:00:00.000Z", true), + ]; + const start = "2026-10-20T06:00:00.000Z", + end = "2026-10-20T12:00:00.000Z"; + const [group] = groupOnlinePeriods(periods, "UTC", start, end); + expect(group.total).toBe(21600); + expect(group.chips[0].start.toISOString()).toBe(start); + expect(group.chips[0].end.toISOString()).toBe(end); + expect(group.chips[0].ongoing).toBe(false); + const [segment] = buildDaySegments(periods, group.date, "UTC", start, end); + expect(segment.leftPct).toBe(25); + expect(segment.widthPct).toBe(25); + }); +}); + +describe("buildDayWindow", () => { + it("distinguishes excluded hours from a partially observed day", () => { + const window = buildDayWindow( + "2026-10-20", + "2026-10-20T06:00:00Z", + "2026-10-20T12:00:00Z", + "UTC", + ); + expect(window.leftPct).toBe(25); + expect(window.rightPct).toBe(50); + expect(window.seconds).toBe(21600); + expect(dailyOnlineRate(21600, window.seconds)).toBe(100); + expect(dailyOnlineRate(0, 0)).toBe(0); + }); + it.each([ + ["2026-03-08", "2026-03-08T05:00:00Z", "2026-03-09T04:00:00Z", 23], + ["2026-11-01", "2026-11-01T04:00:00Z", "2026-11-02T05:00:00Z", 25], + ])("uses actual elapsed time across DST on %s", (date, start, end, hours) => { + const window = buildDayWindow(date, start, end, "America/New_York"); + expect(window.seconds).toBe(Number(hours) * 3600); + expect(window.leftPct).toBe(0); + expect(window.rightPct).toBe(100); + expect(dailyOnlineRate(window.seconds, window.seconds)).toBe(100); + }); +}); + +describe("formatPeriodMoment", () => { + it("renders a segment ending at the next midnight as 24:00", () => { + const day = panelDay("2026-10-01", "UTC"); + expect( + formatPeriodMoment(panelNextDayStart("2026-10-01", "UTC"), day, "UTC"), + ).toBe("24:00"); + expect( + formatPeriodMoment( + panelNextDayStart("2026-10-01", "UTC"), + day, + "UTC", + true, + ), + ).toBe("24:00:00"); + expect( + formatPeriodMoment( + panelDay("2026-10-01T23:00:00.000Z", "UTC"), + day, + "UTC", + ), + ).toBe("23:00"); + }); +}); + +describe("buildDaySegments", () => { + it("maps positions by panel wall clock on a spring-forward day", () => { + const tz = "America/New_York"; + const [fullDay] = buildDaySegments( + [period("2026-03-08T05:00:00.000Z", "2026-03-09T04:00:00.000Z")], + "2026-03-08", + tz, + ); + // 00:00 EST → 次日 00:00 EDT,全天应铺满 0–100% + expect(fullDay.leftPct).toBe(0); + expect(fullDay.widthPct).toBe(100); + + const [noon] = buildDaySegments( + [period("2026-03-08T16:00:00.000Z", "2026-03-08T18:00:00.000Z")], + "2026-03-08", + tz, + ); + // 12:00 EDT 必须落在 50%,而不是 elapsed 11h 的 45.8% + expect(noon.leftPct).toBeCloseTo(50, 5); + expect(noon.widthPct).toBeCloseTo((2 / 24) * 100, 5); + + const [evening] = buildDaySegments( + [period("2026-03-09T02:00:00.000Z", "2026-03-09T04:00:00.000Z")], + "2026-03-08", + tz, + ); + // 22:00 EST → 日界 00:00 EDT:日界必须用挂钟零点,不能加 24h + expect(evening.leftPct).toBeCloseTo((22 / 24) * 100, 5); + expect(evening.widthPct).toBeCloseTo((2 / 24) * 100, 5); + }); + + it("keeps a full day at 100% on a fall-back day", () => { + const [fullDay] = buildDaySegments( + [period("2026-11-01T04:00:00.000Z", "2026-11-02T05:00:00.000Z")], + "2026-11-01", + "America/New_York", + ); + expect(fullDay.leftPct).toBe(0); + expect(fullDay.widthPct).toBe(100); + }); + + it("marks only the segment ending at the live session end as ongoing", () => { + const periods = [ + period("2026-10-01T22:00:00.000Z", "2026-10-02T06:00:00.000Z", true), + ]; + const [previousDay] = buildDaySegments(periods, "2026-10-01", "UTC"); + const [currentDay] = buildDaySegments(periods, "2026-10-02", "UTC"); + + // 前一天被日界裁剪,不能显示为 Ongoing + expect(previousDay.live).toBe(false); + expect(previousDay.segEnd.format("HH:mm")).toBe("00:00"); + // 当天这一段才真正结束于会话末端 + expect(currentDay.live).toBe(true); + expect(currentDay.leftPct).toBe(0); + expect(currentDay.widthPct).toBeCloseTo((6 / 24) * 100, 5); + }); +}); diff --git a/src/test/pages/Server.test.tsx b/src/test/pages/Server.test.tsx index 54b21d0..2db125e 100644 --- a/src/test/pages/Server.test.tsx +++ b/src/test/pages/Server.test.tsx @@ -16,7 +16,15 @@ const apiMocks = vi.hoisted(() => ({ fetchService: vi.fn(), })); -vi.mock("@/lib/nezha-api", () => apiMocks); +vi.mock("@/lib/nezha-api", async () => { + const actual = + await vi.importActual("@/lib/nezha-api"); + return { + ...actual, + fetchServerGroup: apiMocks.fetchServerGroup, + fetchService: apiMocks.fetchService, + }; +}); vi.mock("@/components/GlobalMap", () => ({ default: ({ serverList }: { serverList: NezhaServer[] }) => ( @@ -237,6 +245,32 @@ describe("Servers page", () => { expect(screen.getByText("group failed")).toBeInTheDocument(); }); + it("prompts guests to log in when the API returns ApiErrorUnauthorized", async () => { + apiMocks.fetchServerGroup.mockRejectedValue( + new Error("ApiErrorUnauthorized"), + ); + + renderServerPage({ + connected: false, + lastData: null, + }); + + expect( + await screen.findByText("error.loginRequiredTitle"), + ).toBeInTheDocument(); + expect( + screen.getByText("error.loginRequiredDescription"), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "login" })).toHaveAttribute( + "href", + "/dashboard", + ); + expect( + screen.queryByText("error.backendUnavailableTitle"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("ApiErrorUnauthorized")).not.toBeInTheDocument(); + }); + it("summarizes online and offline servers from websocket data", async () => { const online = createServer({ id: 1, name: "alpha" }); const offline = createServer({ diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts index 23359e7..88834df 100644 --- a/src/types/nezha-api.ts +++ b/src/types/nezha-api.ts @@ -45,6 +45,12 @@ export interface NezhaServerStatus { process_count: number; temperatures: temperature[]; gpu: number[]; + // Per-card figures; index-aligned with host.gpu. Absent on older agents. + gpus?: { + utilization: number; + memory_used?: number; + memory_total?: number; + }[]; } interface temperature { @@ -204,3 +210,36 @@ export interface ServerMetricsResponse { success: boolean; data: ServerMetricsData; } + +export interface OnlinePeriod { + start: string; + end: string; + ongoing: boolean; +} + +export interface DailyOnlineStat { + date: string; + online_seconds: number; +} + +export interface OnlineHistoryData { + server_id: number; + server_name: string; + days: number; + max_days: number; + /** 面板时区(Daily 拆日的日界),前端按同一时区渲染 */ + timezone?: string; + window_start: string; + window_end: string; + total_online_seconds: number; + online_rate: number; + currently_online: boolean; + last_active?: string; + periods: OnlinePeriod[]; + daily: DailyOnlineStat[]; +} + +export interface OnlineHistoryResponse { + success: boolean; + data: OnlineHistoryData; +} diff --git a/vite.config.ts b/vite.config.ts index 8f7ffd3..cf2906a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,16 @@ import path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; +// Node ≥25 默认启用实验性 webstorage:其全局 localStorage 在无有效存储文件时 +// 会遮蔽 jsdom 注入的同名全局(localStorage.clear 变为非函数)。测试 worker +// 进程统一禁用,保证 jsdom 的 storage 生效。 +process.env.NODE_OPTIONS = [ + process.env.NODE_OPTIONS, + "--no-experimental-webstorage", +] + .filter(Boolean) + .join(" "); + // Get git commit hash const getGitHash = () => { try {