diff --git a/docs/console-design.md b/docs/console-design.md index 585b11d..db1fccd 100644 --- a/docs/console-design.md +++ b/docs/console-design.md @@ -124,6 +124,8 @@ Rules: - A module imports from `@/shell` only, never from a file under it or from another module. One exception: `inferences` builds on swiss (see "The inferences module"). - `permission` is a UI permission (a Role's `uiPermissions`); it guards the route and hides the menu entry. +- `topbar: true` adds the page's menu entry to the header; `topbar: { label: "模型广场" }` gives it a separate header label without changing the sidebar or route. +- The first home overview receives shell-owned session content through `ModuleOverviewProps.session`; it places this below cluster information, or renders it alone when inference viewing is forbidden. Without overview modules, Home renders the session itself. - Links go through `useModulePath()`: `p("catalog")` → `/inferences/catalog`. A leading `/` is still relative to the module. - API calls go through `apiFetch` (drop-in `fetch` with the session) or `request` (JSON helper). - The sidebar opens the group of the module the current path is mounted under (`activeGroupTitle`), so a page with no menu entry -- a detail page -- still keeps its module's group open. @@ -159,6 +161,7 @@ derived key, falling back to the literal: |---|---| | module `title` | `{id}:group.{title}` | | page `menu.label` | `{id}:menu.{pageKey(path)}`: `""` → `index`, `login-history` → `loginHistory` | +| page `topbar.label` | `{id}:menu.{pageKey(path)}Topbar`; Chinese uses the declared label | So `group`/`menu` appear only in the non-Chinese files. `t()` returns one function per (locale, namespace): a fresh one per render would re-run every @@ -232,6 +235,7 @@ service layout. It replaced the swiss module's own pages, Model Deployment at | Page | Path | Layout | |---|---|---| +| Home dashboard | `/` | six capability/resource totals; primary 24-hour deployment curve beside capacity gauges, followed by the live service table and a two-column resources/activity row; cluster information and collapsed personal session in the right rail | | Inference services | `/inferences` | resource table, server-side paging, row menu | | Service detail | `/inferences/:release/details?namespace=…&tab=…` | header card over tabs: overview, instances, logs, events, cluster resources, health check, versions, activity, plan; SLO edited in a sheet from the header | | Model library | `/inferences/catalog?catalog=…&q=…` | card grid, facet filters in the URL (swiss's parameter names) | @@ -241,6 +245,23 @@ service layout. It replaced the swiss module's own pages, Model Deployment at | Site profile | `/inferences/site-profile` | grouped read view; edit in a sheet, form or YAML | | Setup | `/inferences/setup` | first run; swiss's gate sends an uninitialised site here | +The home overview is gated by `swiss.view` and does not run `SwissScope`, so an uninitialised backend cannot redirect the whole console home to setup. + +| Dashboard data | Contract | +|---|---| +| Service total | server summary, not the six-row preview count | +| Capability totals | all configured catalogs; non-deprecated models deduplicated by model/source/revision identity, recipes from all listed versions deduplicated by that identity/version/digest/variant id; any unreadable catalog keeps totals unknown | +| Deployment curve | last 24 hours of completed audit operations by `startedAt`; changed successful installs are starts, changed successful apply/rollback are updates, failed install/apply/rollback are failures; not a history of Pods serving | +| Timeline completeness | use the exact backend `/api/runs/timeline` aggregate; old servers may use uncapped-length responses below 500 rows, but reaching that legacy cap is incomplete and never shown as a complete zero curve; no database remains unknown | +| Cluster sidebar | name, namespace, deployment mode and swissd version from cluster info; distinct Kubernetes node versions from `Node.Kubelet`, never from the swissd version; ready/schedulable counts from node inventory | +| Service state and instances | live release status for each preview row; a deployed Helm release alone is not serving | +| Allocated GPUs | pod resource allocation, not GPU utilisation; `usageError` means unknown | +| Resource charts | node allocation bars and GPU allocation/node online gauges from the existing nodes snapshot; unavailable readings stay unknown, with no synthetic GPU-utilisation or token/request history | +| Refresh | services/status every 15 s, activity every 20 s, nodes every 30 s; no overview-wide refresh control, with local retries on read failures | +| Read failures | inline error and retry, never a successful empty state | +| Service preview | standard `ResourceTable` surface and title header; `pagination={false}` renders all supplied rows and `emptySize="compact"` bounds the empty state | +| Activity preview | five recent operations in a compact `SectionCard` feed, with timestamps, result and detail links | + Writes are right-side sheets, as Rise Global's are: ``` diff --git a/web/package-lock.json b/web/package-lock.json index f1cfe91..57a7127 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,6 +21,7 @@ "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "react-router": "^8.4.0", + "recharts": "^3.10.1", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.7.0" @@ -556,6 +557,32 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/route-pattern": { "version": "0.22.1", "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", @@ -824,6 +851,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1212,6 +1251,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1302,6 +1404,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -1309,6 +1417,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1325,6 +1434,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1341,6 +1451,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1357,6 +1468,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1373,6 +1485,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1389,6 +1502,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1405,6 +1519,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1421,6 +1536,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1437,6 +1553,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1453,6 +1570,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1469,6 +1587,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1485,6 +1604,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1501,6 +1621,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1517,6 +1638,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1533,6 +1655,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1549,6 +1672,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1565,6 +1689,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1581,6 +1706,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1597,6 +1723,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1613,6 +1740,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1864,6 +1992,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1881,6 +2130,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -1953,6 +2208,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -1975,6 +2242,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2180,12 +2453,31 @@ "intl-messageformat": ">=10.3.3 <12.0.0" } }, + "node_modules/immer": { + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz", + "integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "11.2.15", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.15.tgz", @@ -3609,6 +3901,13 @@ "react": "^19.3.0" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -3636,6 +3935,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -3727,6 +4049,51 @@ } } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/rehype-highlight": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", @@ -3955,6 +4322,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", @@ -4253,6 +4626,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", diff --git a/web/package.json b/web/package.json index d045951..3d4c87b 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "react-router": "^8.4.0", + "recharts": "^3.10.1", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.7.0" diff --git a/web/packages/ui/src/components/resource-table-pagination.test.tsx b/web/packages/ui/src/components/resource-table-pagination.test.tsx new file mode 100644 index 0000000..ed5c7a3 --- /dev/null +++ b/web/packages/ui/src/components/resource-table-pagination.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ResourceTable } from "./resource-table"; + +const data = [{ id: "qwen", name: "Qwen3" }]; +const columns = [{ key: "name", title: "Model" }]; + +function render(pagination?: boolean) { + return renderToStaticMarkup( + , + ); +} + +describe("resource table previews", () => { + it("keeps normal resource pages at their default empty size", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("px-0 py-14"); + }); + + it.each([null, new Error("preview-read-failed")])("uses compact spacing for empty or failed previews", (error) => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("px-0 py-6"); + expect(html).toContain("gap-3 p-0 md:p-0"); + expect(html).not.toContain("py-14"); + }); + it("keeps pagination visible by default", () => { + expect(render()).toContain('data-slot="pagination"'); + }); + + it("does not slice supplied rows when pagination is disabled", () => { + const rows = Array.from({ length: 12 }, (_, i) => ({ id: String(i), name: `model-${i}` })); + const html = renderToStaticMarkup( + , + ); + for (const row of rows) expect(html).toContain(row.name); + }); + + it("hides pagination for a bounded dashboard preview without hiding its rows", () => { + const html = render(false); + expect(html).toContain("Qwen3"); + expect(html).not.toContain('data-slot="pagination"'); + expect(html).toContain("border-border bg-card"); + }); +}); diff --git a/web/packages/ui/src/components/resource-table.tsx b/web/packages/ui/src/components/resource-table.tsx index 0b36c41..9a017f5 100644 --- a/web/packages/ui/src/components/resource-table.tsx +++ b/web/packages/ui/src/components/resource-table.tsx @@ -299,6 +299,8 @@ export interface ResourceTableProps { onSortChange?: (sort: SortState | null) => void // ── 分页 ──────────────────────────────────────────────────────────────── + /** 关闭时显示全部传入数据,适用于首页等有界预览。 */ + pagination?: boolean /** static:内部分页 + 内部排序;remote:只回调,数据原样渲染 */ mode?: "static" | "remote" page?: number @@ -404,6 +406,7 @@ export interface ResourceTableProps { emptyDescription?: string /** 「一条都还没有」时的行动号召(通常是创建按钮) */ emptyAction?: React.ReactNode + emptySize?: "default" | "compact" className?: string } @@ -771,6 +774,7 @@ export function ResourceTable>({ sort: sortProp, onSortChange, mode = "static", + pagination = true, page: pageProp, pageSize: pageSizeProp, total: totalProp, @@ -805,6 +809,7 @@ export function ResourceTable>({ emptyTitle, emptyDescription, emptyAction, + emptySize = "default", className, }: ResourceTableProps) { const t = useUiT() @@ -862,10 +867,10 @@ export function ResourceTable>({ const total = mode === "remote" ? (totalProp ?? 0) : sorted.length const pageRows = React.useMemo(() => { - if (mode === "remote") return sorted + if (mode === "remote" || !pagination) return sorted const start = (page - 1) * pageSize return sorted.slice(start, start + pageSize) - }, [sorted, mode, page, pageSize]) + }, [sorted, mode, pagination, page, pageSize]) // ── 选择:跨页保留 ─────────────────────────────────────────────────────── // 存的是 rowKey 的值 + 行快照。存快照是为了批量动作能作用到**已翻走的页**上 @@ -1743,7 +1748,7 @@ export function ResourceTable>({ {/* 三种「没内容」的处境完全不同,文案与行动号召也不同 */} {!isInitialLoading && error && ( - + {/* `sticky left-0` + 可视宽度:这个 td 的宽度是**整张表**的宽度 (列多时远宽于可视区),里面 text-center 是在那个宽度里居中的, @@ -1751,7 +1756,7 @@ export function ResourceTable>({ 与行展开面板同一套解法,`viewWidth` 也是同一个 ResizeObserver 量的。 */}
- + @@ -1774,12 +1779,12 @@ export function ResourceTable>({ {!isInitialLoading && !error && pageRows.length === 0 && ( - + {/* 同上:不包一层 sticky 的话,空态会按整张表的宽度居中(理由见上一处) */}
{hasChips ? ( // 有筛选 → 是「筛没了」,该给「清除筛选」而不是「去创建」 - + @@ -1796,7 +1801,7 @@ export function ResourceTable>({ )} ) : ( - + @@ -2028,7 +2033,7 @@ export function ResourceTable>({
{/* ══ 分页 ══ */} - {total > 0 && ( + {pagination && total > 0 && (
- - - - ); -} - export const iamModule: ConsoleModule = { id: "iam", title: "访问控制", basePath: "/iam", frame: "flush", - overview: Overview, pages: [ { path: "users", element: , permission: "users.view", menu: { label: "用户", icon: UsersIcon } }, - { path: "login-history", element: , permission: "loginrecords.view", menu: { label: "登录历史", icon: History } }, + { + path: "login-history", + element: , + permission: "loginrecords.view", + menu: { label: "登录历史", icon: History }, + }, { path: "roles", element: , permission: "roles.view", menu: { label: "角色", icon: ShieldCheck } }, ], }; diff --git a/web/src/modules/inferences/ClusterOverview.tsx b/web/src/modules/inferences/ClusterOverview.tsx new file mode 100644 index 0000000..6535ca3 --- /dev/null +++ b/web/src/modules/inferences/ClusterOverview.tsx @@ -0,0 +1,86 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import { Link } from "react-router"; +import { ArrowRight, Server } from "lucide-react"; +import { Badge, PropertyList, SectionCard } from "@modelsphere/ui"; +import type { ClusterInfo, NodesResponse } from "@swiss/lib/api"; +import { useModulePath } from "@/shell"; +import { useT } from "@/modules/inferences/i18n"; + +export function ClusterOverview({ + cluster, + nodes, +}: { + cluster: UseQueryResult; + nodes: UseQueryResult; +}) { + const t = useT(); + const p = useModulePath(); + const data = cluster.data; + const inventory = nodes.error ? undefined : nodes.data; + const versions = [...new Set((inventory?.nodes ?? []).map((n) => n.Kubelet).filter((v): v is string => !!v))].sort( + (a, b) => a.localeCompare(b, undefined, { numeric: true }), + ); + const ready = inventory?.nodes.filter((n) => n.Ready).length; + const schedulable = inventory?.nodes.filter((n) => n.Ready && n.Schedulable).length; + const nodeCount = inventory?.summary.nodes; + const label = (key: string) => t(`dashboard.cluster.${key}`); + const count = (n?: number) => (n === undefined || nodeCount === undefined ? "-" : `${n} / ${nodeCount}`); + + return ( +
+ } + actions={ + + {label("config")} + + } + loading={cluster.isPending} + error={cluster.error} + onRetry={() => void cluster.refetch()} + > + + {versions.map((version) => ( + + {version} + + ))} + + ) : ( + "-" + ), + }, + { label: label("namespace"), value: data?.namespace || "-" }, + { label: label("ready"), value: count(ready) }, + { label: label("schedulable"), value: count(schedulable) }, + { label: label("backend"), value: data?.version || "-" }, + { + label: label("mode"), + value: data ? ( + + {label(data.allowDeploy ? "deployEnabled" : "readOnly")} + + ) : ( + "-" + ), + }, + ]} + /> + +
+ ); +} diff --git a/web/src/modules/inferences/Dashboard.tsx b/web/src/modules/inferences/Dashboard.tsx new file mode 100644 index 0000000..72b1fe7 --- /dev/null +++ b/web/src/modules/inferences/Dashboard.tsx @@ -0,0 +1,370 @@ +import { useQueries, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { Link } from "react-router"; +import { + Activity, + ArrowRight, + Bot, + BrainCircuit, + CircleCheck, + Cpu, + Layers, + Loader2, + Plus, + RefreshCw, + TriangleAlert, + type LucideIcon, +} from "lucide-react"; +import { + Alert, + AlertDescription, + Badge, + Button, + Card, + CardContent, + ResourceTable, + SectionCard, + type ResourceColumn, +} from "@modelsphere/ui"; +import { formatDateTime, PermissionGuard, useModulePath, type ModuleOverviewProps } from "@/shell"; +import { api, type Deployment, type NodesResponse, type ReleaseStatus } from "@swiss/lib/api"; +import { useT } from "@/modules/inferences/i18n"; +import { detailPath, modelLine, releaseState, rowState } from "@/modules/inferences/lib"; +import { StatusDot } from "@/modules/inferences/components/StatusDot"; +import { ResourceOverview } from "@/modules/inferences/ResourceOverview"; +import { ClusterOverview } from "@/modules/inferences/ClusterOverview"; +import { DeploymentTrend } from "@/modules/inferences/DeploymentTrend"; +import { useSupportedTotals } from "@/modules/inferences/useSupportedTotals"; +import { allDeployments } from "@/modules/inferences/dashboard-api"; +import { healthRank, serviceHealth } from "@/modules/inferences/dashboard-lib"; + +export const DASHBOARD_PAGE_SIZE = 6; + +type Row = Deployment & { key: string; live: UseQueryResult }; + +export function InferencesOverview({ session }: ModuleOverviewProps) { + return ( + {session}
}> + + + ); +} + +function Dashboard({ session }: ModuleOverviewProps) { + const t = useT(); + const p = useModulePath(); + const list = useQuery({ + queryKey: ["deployments", "dashboard", "all"], + queryFn: allDeployments, + refetchInterval: 15_000, + placeholderData: (prev) => prev, + }); + const nodes = useQuery({ queryKey: ["nodes"], queryFn: api.nodes, refetchInterval: 30_000 }); + const runs = useQuery({ + queryKey: ["runs", "dashboard"], + queryFn: () => api.runs({ limit: 5 }), + refetchInterval: 20_000, + }); + const cluster = useQuery({ queryKey: ["cluster"], queryFn: api.cluster }); + const supported = useSupportedTotals(cluster); + + const deployments = list.data?.deployments ?? []; + const statuses = useQueries({ + queries: deployments.map((d) => ({ + queryKey: ["status", d.namespace, d.release], + queryFn: () => api.status(d.namespace, d.release), + refetchInterval: 15_000, + })), + }); + const rows: Row[] = deployments.map((d, i) => ({ ...d, key: `${d.namespace}/${d.release}`, live: statuses[i] })); + // Health numbers wait until every status read has settled, so a slow read can + // never turn into a healthy-looking zero. + const settled = rows.every((_, i) => statuses[i] && (statuses[i].data || statuses[i].error)); + const health = settled ? serviceHealth(rows, statuses) : null; + const shown = rows + .slice() + .sort((a, b) => healthRank(a, a.live) - healthRank(b, b.live) || a.release.localeCompare(b.release)) + .slice(0, DASHBOARD_PAGE_SIZE); + + const columns: ResourceColumn[] = [ + { + key: "release", + title: t("list.columns.name"), + width: 240, + hideable: false, + render: (row) => ( +
+ + {row.release} + + + {modelLine(row) || t("list.untracked")} + +
+ ), + }, + { + key: "state", + title: t("list.columns.state"), + width: 150, + render: (row) => + row.live.error ? ( + + ) : ( + + ), + }, + { + key: "instances", + title: t("dashboard.instances"), + width: 100, + render: (row) => ( + {row.live.data ? `${row.live.data.ready} / ${row.live.data.total}` : "-"} + ), + }, + { key: "namespace", title: t("list.columns.namespace"), width: 150 }, + { + key: "route", + title: t("list.columns.route"), + width: 160, + render: (row) => {row.route ? `/${row.route}/` : "-"}, + }, + ]; + const all = (path: string) => ( + + {t("actions.viewAll")} + + + ); + const metrics = [ + { icon: BrainCircuit, label: t("dashboard.supportedModels"), value: supported.totals?.models ?? "-", path: "catalog" }, + { icon: Layers, label: t("dashboard.recipeTotal"), value: supported.totals?.recipes ?? "-", path: "catalog" }, + { icon: CircleCheck, label: t("dashboard.serving"), value: health?.serving ?? "-", path: "" }, + { icon: TriangleAlert, label: t("dashboard.failedServices"), value: health?.failed ?? "-", path: "" }, + { icon: Loader2, label: t("dashboard.changing"), value: health?.degraded ?? "-", path: "" }, + { icon: Bot, label: t("dashboard.instances"), value: health ? `${health.ready} / ${health.instances}` : "-", path: "" }, + ]; + + return ( +
+
+ {metrics.map((metric) => ( + + + + ))} +
+ {supported.error && ( + + )} + {health && health.unknown > 0 && ( + + statuses.forEach((s) => { + if (s.error) void s.refetch(); + }) + } + /> + )} + {cluster.data?.warnings?.map((message) => ( + + ))} + {nodes.data?.usageError && ( + void nodes.refetch()} /> + )} + +
+
+
+ + +
+
+ {list.isPending && ( + + {t("common:status.loading")} + + )} + + title={t("list.title")} + titleIcon={} + titleSummary={list.data && t("dashboard.preview", { shown: shown.length, total: list.data.summary.total })} + titleActions={all("")} + toolbarLayout="inline" + data={shown} + columns={columns} + rowKey="key" + loading={list.isPending} + error={list.error} + onRetry={() => void list.refetch()} + pagination={false} + showColumnToggle={false} + emptySize="compact" + emptyTitle={t("list.empty")} + emptyAction={ + + } + /> +
+
+ +
+ } + actions={all("runs")} + padded={false} + loading={runs.isPending} + error={runs.error} + onRetry={() => void runs.refetch()} + > + {runs.data?.runs.length ? ( +
    + {runs.data.runs.map((run) => ( +
  1. +
    + + {run.release} + + + {t(run.error ? "runs.failed" : run.changed ? "runs.changed" : "runs.unchanged")} + +
    +
    + {t(`runsPage.actions.${run.action}`, { defaultValue: run.action })} + +
    +
  2. + ))} +
+ ) : ( +
+ + {t(runs.data?.hasStore === false ? "overview.noStore" : "runs.empty")} +
+ )} +
+
+
+
+ + +
+
+ ); +} + +function CapacityGauges({ nodes }: { nodes: UseQueryResult }) { + const t = useT(); + const data = nodes.data; + const known = !!data && !data.usageError; + const ready = data ? data.nodes.filter((n) => n.Ready).length : null; + const gauges = [ + { + label: t("dashboard.resources.allocation"), + value: known ? data.summary.gpusUsed : null, + total: data?.summary.gpus ?? null, + color: "var(--info)", + detail: known + ? t("dashboard.resources.freeCount", { n: Math.max(0, data!.summary.gpus - data!.summary.gpusUsed) }) + : t("dashboard.resources.unknown"), + }, + { + label: t("dashboard.resources.online"), + value: ready, + total: data?.summary.nodes ?? null, + color: "var(--success)", + detail: + ready === null || !data + ? t("dashboard.resources.unknown") + : t("dashboard.resources.readyCount", { ready, total: data.summary.nodes }), + }, + ]; + return ( +
+ } + loading={nodes.isPending} + error={nodes.error} + onRetry={() => void nodes.refetch()} + > +
+ {gauges.map((gauge) => { + const value = gauge.value; + const total = gauge.total; + const measured = value !== null && total !== null && total > 0; + const percent = measured ? Math.min(100, Math.max(0, Math.round((value / total) * 100))) : null; + const angle = Math.min(100, percent ?? 0) * 3.6; + return ( +
+
+
+ {percent === null ? "-" : `${percent}%`} +
+
{gauge.label}
+
{gauge.detail}
+
+ ); + })} +
+ +
+ ); +} + +function Metric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: number | string }) { + return ( + + +
+ {label} + +
+
{value}
+
+
+ ); +} + +function QueryWarning({ message, retry }: { message: string; retry?: () => void }) { + const t = useT(); + return ( + + + + {message} + {retry && ( + + )} + + + ); +} diff --git a/web/src/modules/inferences/DeploymentTrend.tsx b/web/src/modules/inferences/DeploymentTrend.tsx new file mode 100644 index 0000000..a3275d8 --- /dev/null +++ b/web/src/modules/inferences/DeploymentTrend.tsx @@ -0,0 +1,74 @@ +import { lazy, Suspense } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router"; +import { ArrowRight, ChartNoAxesCombined, Clock, TriangleAlert } from "lucide-react"; +import { SectionCard, Skeleton } from "@modelsphere/ui"; +import { useModulePath } from "@/shell"; +import { readDeploymentTimeline } from "@/modules/inferences/dashboard-api"; +import { timelineTotals } from "@/modules/inferences/dashboard-lib"; +import { useT } from "@/modules/inferences/i18n"; + +const TrendPlot = lazy(() => import("@/modules/inferences/TrendPlot").then((m) => ({ default: m.TrendPlot }))); + +export function DeploymentTrend() { + const t = useT(); + const p = useModulePath(); + const history = useQuery({ + queryKey: ["runs", "dashboard", "timeline"], + queryFn: () => readDeploymentTimeline(), + refetchInterval: 30_000, + }); + const totals = history.data ? timelineTotals(history.data) : null; + + return ( +
+ } + summary={t("dashboard.trend.range")} + actions={ + + {t("dashboard.trend.records")} + + } + padded={false} + loading={history.isPending} + error={history.error} + onRetry={() => void history.refetch()} + contentClassName="min-h-[288px]" + > + {!history.data?.hasStore ? ( +
+ + {t("dashboard.trend.noHistory")} +
+ ) : !history.data.complete ? ( +
+ + {t("dashboard.trend.incomplete")} +
+ ) : ( +
+
+ {(["started", "updated", "failed"] as const).map((key) => ( +
+
{t(`dashboard.trend.${key}`)}
+
{totals?.[key] ?? "-"}
+
+ ))} +
+
+ }> + + +
+
+ )} +
+
+ ); +} diff --git a/web/src/modules/inferences/ResourceOverview.tsx b/web/src/modules/inferences/ResourceOverview.tsx new file mode 100644 index 0000000..679338f --- /dev/null +++ b/web/src/modules/inferences/ResourceOverview.tsx @@ -0,0 +1,77 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import { Link } from "react-router"; +import { ArrowRight, Cpu } from "lucide-react"; +import { SectionCard, StatusIndicator } from "@modelsphere/ui"; +import type { NodesResponse } from "@swiss/lib/api"; +import { useModulePath } from "@/shell"; +import { useT } from "@/modules/inferences/i18n"; +import { nodeState } from "@/modules/inferences/nodes-lib"; + +export function ResourceOverview({ nodes }: { nodes: UseQueryResult }) { + const t = useT(); + const p = useModulePath(); + const data = nodes.data; + const known = !!data && !data.usageError; + const gpuNodes = [...(data?.nodes ?? [])] + .filter((n) => n.GPUs > 0) + .sort((a, b) => b.GPUs - a.GPUs || a.Name.localeCompare(b.Name)); + return ( +
+ } + actions={ + + {t("nodes.title")} + + + } + loading={nodes.isPending} + error={nodes.error} + onRetry={() => void nodes.refetch()} + > + {gpuNodes.length ? ( +
    + {gpuNodes.slice(0, 4).map((node) => { + const state = nodeState(node); + const measured = known && node.gpusUsed !== undefined; + const used = measured ? node.gpusUsed! : null; + const percentage = used === null ? 0 : Math.min(100, Math.max(0, (used / node.GPUs) * 100)); + return ( +
  • +
    + + {node.Name} + + +
    +
    +
    +
    +
    + + {node.GPUProduct || node.GPUResource || "GPU"} + + + {used ?? "-"} / {node.GPUs} + +
    +
  • + ); + })} +
+ ) : ( +

{t("dashboard.resources.noNodes")}

+ )} +
+
+ ); +} diff --git a/web/src/modules/inferences/TrendPlot.tsx b/web/src/modules/inferences/TrendPlot.tsx new file mode 100644 index 0000000..41b8ed8 --- /dev/null +++ b/web/src/modules/inferences/TrendPlot.tsx @@ -0,0 +1,81 @@ +import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { useLocale } from "@/shell"; +import type { DeploymentBucket } from "@/modules/inferences/dashboard-lib"; +import { useT } from "@/modules/inferences/i18n"; + +export function TrendPlot({ buckets }: { buckets: DeploymentBucket[] }) { + const t = useT(); + const { locale } = useLocale(); + const hour = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", hour12: false }); + const full = new Intl.DateTimeFormat(locale, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const data = buckets.map((bucket) => ({ ...bucket, stamp: Date.parse(bucket.at) })); + return ( + + + + hour.format(new Date(value))} + minTickGap={70} + tick={{ fill: "var(--muted-foreground)", fontSize: 11 }} + tickLine={false} + axisLine={false} + /> + Math.max(4, max)]} + tick={{ fill: "var(--muted-foreground)", fontSize: 11 }} + tickLine={false} + axisLine={false} + width={46} + /> + full.format(new Date(Number(label)))} + contentStyle={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: 6, fontSize: 12 }} + /> + item.dataKey === "started" ? 0 : item.dataKey === "updated" ? 1 : 2} wrapperStyle={{ fontSize: 12, paddingTop: 12 }} /> + + + + + + ); +} diff --git a/web/src/modules/inferences/dashboard-api.test.ts b/web/src/modules/inferences/dashboard-api.test.ts new file mode 100644 index 0000000..32466da --- /dev/null +++ b/web/src/modules/inferences/dashboard-api.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readDeploymentTimeline } from "./dashboard-api"; +import { HISTORY_LIMIT, recordedTimeline } from "./dashboard-lib"; + +const from = "2026-10-10T12:15:00.000Z"; +const to = "2026-10-11T12:15:00.000Z"; +const now = Date.parse(to); +const fetchMock = vi.fn(); +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } }); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("document", { cookie: "" }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("deployment timeline API compatibility", () => { + it("uses the complete aggregate without downloading individual history rows", async () => { + const data = recordedTimeline([], from, to, true); + data.buckets[2].started = 601; + fetchMock.mockResolvedValue(json(data)); + const result = await readDeploymentTimeline(now); + expect(result.complete).toBe(true); + expect(result.buckets[2].started).toBe(601); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toContain("/api/deploy/runs/timeline?from="); + }); + it("supports the old server's run-id error and requests its actual 500-row limit", async () => { + fetchMock + .mockResolvedValueOnce(json({ error: "run id must be a number" }, 400)) + .mockResolvedValueOnce(json({ hasStore: true, runs: [] })); + const result = await readDeploymentTimeline(now); + expect(result.hasStore).toBe(true); + expect(result.complete).toBe(true); + expect(result.buckets).toHaveLength(25); + expect(String(fetchMock.mock.calls[1][0])).toContain("limit=500"); + }); + it("marks old capped data incomplete instead of returning a fake complete zero chart", async () => { + fetchMock + .mockResolvedValueOnce(json({ error: "not found" }, 404)) + .mockResolvedValueOnce(json({ hasStore: true, runs: Array.from({ length: HISTORY_LIMIT }, () => ({})) })); + const result = await readDeploymentTimeline(now); + expect(result.complete).toBe(false); + expect(result.buckets).toEqual([]); + }); + it("keeps a server without persistence distinct from zero operations", async () => { + fetchMock + .mockResolvedValueOnce(json({ error: "no database: this swissd keeps no operation log" }, 503)) + .mockResolvedValueOnce(json({ hasStore: false, runs: [] })); + const result = await readDeploymentTimeline(now); + expect(result.hasStore).toBe(false); + expect(result.buckets).toEqual([]); + }); + it.each([403, 500, 503])("does not mask unrelated HTTP %s failures", async (status) => { + fetchMock.mockResolvedValue(json({ error: "real backend failure" }, status)); + await expect(readDeploymentTimeline(now)).rejects.toThrow("real backend failure"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("does not treat malformed successful responses as valid history", async () => { + fetchMock.mockResolvedValue(json({ ok: true })); + await expect(readDeploymentTimeline(now)).rejects.toThrow("Invalid deployment timeline"); + }); +}); diff --git a/web/src/modules/inferences/dashboard-api.ts b/web/src/modules/inferences/dashboard-api.ts new file mode 100644 index 0000000..e4b5d0b --- /dev/null +++ b/web/src/modules/inferences/dashboard-api.ts @@ -0,0 +1,37 @@ +import { ApiError, request } from "@/shell"; +import { api, type DeploymentsResponse } from "@swiss/lib/api"; +import { apiPath } from "@swiss/lib/host"; +import { HISTORY_LIMIT, HOUR_MS, recordedTimeline, type DeploymentTimeline } from "@/modules/inferences/dashboard-lib"; + +export async function readDeploymentTimeline(now = Date.now()): Promise { + const from = new Date(now - 24 * HOUR_MS).toISOString(); + const to = new Date(now).toISOString(); + const query = new URLSearchParams({ from, to }); + try { + const data = await request>("GET", apiPath(`/api/runs/timeline?${query}`)); + if (typeof data.hasStore !== "boolean" || !Array.isArray(data.buckets)) + throw new Error("Invalid deployment timeline response"); + return { ...data, complete: true }; + } catch (error) { + const unsupported = + error instanceof ApiError && + (error.status === 404 || + error.status === 405 || + (error.status === 400 && error.message === "run id must be a number") || + (error.status === 503 && error.message === "no database: this swissd keeps no operation log")); + if (!unsupported) throw error; + const data = await api.runs({ limit: HISTORY_LIMIT }); + return recordedTimeline(data.runs, from, to, data.hasStore); + } +} + +// swissd caps a page at 100; health counts must cover every service, so keep +// paging until the server total is reached. +const PAGE = 100; +export async function allDeployments(): Promise { + const first = await api.deployments(1, PAGE); + const pages = Math.ceil(first.summary.total / PAGE); + if (pages <= 1) return first; + const rest = await Promise.all(Array.from({ length: pages - 1 }, (_, i) => api.deployments(i + 2, PAGE))); + return { ...first, deployments: [...first.deployments, ...rest.flatMap((r) => r.deployments)] }; +} diff --git a/web/src/modules/inferences/dashboard-lib.test.ts b/web/src/modules/inferences/dashboard-lib.test.ts new file mode 100644 index 0000000..b9b9895 --- /dev/null +++ b/web/src/modules/inferences/dashboard-lib.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import type { CatalogResponse, Run } from "@swiss/lib/api"; +import { HISTORY_LIMIT, recordedTimeline, supportedTotals, timelineTotals } from "./dashboard-lib"; + +const from = "2026-10-10T12:15:00.000Z"; +const to = "2026-10-11T12:15:00.000Z"; +const run = (id: number, startedAt: string, action = "install", extra: Partial = {}): Run => ({ + id, + startedAt, + endedAt: startedAt, + action, + namespace: "models", + release: `model-${id}`, + planHash: "hash", + changed: true, + ...extra, +}); +const catalog: CatalogResponse = { + name: "default", + ref: "main", + source: "https://catalog.example", + index: { + apiVersion: "catalog.swiss/v1", + count: 1, + models: [ + { + name: "alpha", + source: { hf: "org/alpha" }, + latest: "2", + versions: [ + { + version: "1", + path: "alpha-1.yaml", + digest: "old", + variants: [{ id: "base", engine: "vllm", chart: { name: "vllm", version: "1" }, requires: { gpus: 1 } }], + }, + { + version: "2", + path: "alpha-2.yaml", + digest: "new", + variants: [ + { id: "base", engine: "vllm", chart: { name: "vllm", version: "2" }, requires: { gpus: 1 } }, + { id: "tuned", engine: "sglang", chart: { name: "sglang", version: "2" }, requires: { gpus: 2 } }, + ], + }, + ], + }, + ], + }, +}; + +describe("supported catalog totals", () => { + it("counts every supported version recipe, not a sample or only the latest", () => + expect(supportedTotals([catalog])).toEqual({ models: 1, recipes: 3 })); + it("does not double-count mirrored catalog entries", () => + expect(supportedTotals([catalog, { ...catalog, name: "mirror" }])).toEqual({ models: 1, recipes: 3 })); + it("does not count deprecated models as current support", () => { + const old = { + ...catalog, + index: { ...catalog.index, models: catalog.index.models.map((m) => ({ ...m, deprecated: "retired" })) }, + }; + expect(supportedTotals([old])).toEqual({ models: 0, recipes: 0 }); + }); + it("distinguishes different model sources and recipe revisions", () => { + const other = { + ...catalog, + index: { ...catalog.index, models: catalog.index.models.map((m) => ({ ...m, source: { hf: "other/alpha" } })) }, + }; + expect(supportedTotals([catalog, other])).toEqual({ models: 2, recipes: 6 }); + }); +}); + +describe("recorded deployment timeline", () => { + it("zero-fills a complete 24-hour window including partial edge hours", () => { + const data = recordedTimeline([], from, to, true); + expect(data.buckets).toHaveLength(25); + expect(data.buckets[0].at).toBe("2026-10-10T12:00:00.000Z"); + expect(data.buckets.at(-1)?.at).toBe("2026-10-11T12:00:00.000Z"); + expect(timelineTotals(data)).toEqual({ started: 0, updated: 0, failed: 0 }); + }); + it("includes the start boundary and excludes the end and future records", () => { + const data = recordedTimeline( + [run(1, from), run(2, "2026-10-10T12:14:59Z"), run(3, to), run(4, "2026-10-11T13:00:00Z")], + from, + to, + true, + ); + expect(timelineTotals(data)?.started).toBe(1); + }); + it("classifies starts updates rollbacks and failures and excludes no-op or unrelated operations", () => { + const at = "2026-10-10T14:30:00Z"; + const data = recordedTimeline( + [ + run(1, at), + run(2, at, "apply"), + run(3, at, "rollback"), + run(4, at, "install", { error: "failed", changed: false }), + run(5, at, "apply", { changed: false }), + run(6, at, "diff"), + run(7, at, "uninstall"), + ], + from, + to, + true, + ); + expect(timelineTotals(data)).toEqual({ started: 1, updated: 2, failed: 1 }); + expect(data.buckets.find((b) => b.at.includes("14:00:00"))).toMatchObject({ started: 1, updated: 2, failed: 1 }); + }); + it("buckets offset timestamps by their actual UTC instant", () => { + const data = recordedTimeline([run(1, "2026-10-10T16:30:00+02:00")], from, to, true); + expect(data.buckets.find((b) => b.at === "2026-10-10T14:00:00.000Z")?.started).toBe(1); + }); + it("never fabricates a zero curve when persistence is absent", () => { + const data = recordedTimeline([], from, to, false); + expect(data.buckets).toEqual([]); + expect(timelineTotals(data)).toBeNull(); + }); + it("does not treat a capped history response as a complete day", () => { + const data = recordedTimeline( + Array.from({ length: HISTORY_LIMIT }, (_, i) => run(i, from)), + from, + to, + true, + ); + expect(data.complete).toBe(false); + expect(data.buckets).toEqual([]); + expect(timelineTotals(data)).toBeNull(); + }); + it("reports malformed relevant timestamps instead of silently hiding events", () => + expect(() => recordedTimeline([run(1, "bad-time")], from, to, true)).toThrow("timestamp")); + it("rejects invalid or inverted time windows", () => + expect(() => recordedTimeline([], to, from, true)).toThrow("range")); +}); diff --git a/web/src/modules/inferences/dashboard-lib.ts b/web/src/modules/inferences/dashboard-lib.ts new file mode 100644 index 0000000..203d531 --- /dev/null +++ b/web/src/modules/inferences/dashboard-lib.ts @@ -0,0 +1,125 @@ +import type { CatalogResponse, Deployment, ReleaseStatus, Run } from "@swiss/lib/api"; +import { releaseState, rowState, type State } from "@/modules/inferences/lib"; + +export const HOUR_MS = 3_600_000; +export const HISTORY_LIMIT = 500; + +export interface DeploymentBucket { + at: string; + started: number; + updated: number; + failed: number; +} + +export interface DeploymentTimeline { + hasStore: boolean; + from: string; + to: string; + buckets: DeploymentBucket[]; + complete: boolean; +} + +export function supportedTotals(catalogs: CatalogResponse[]): { models: number; recipes: number } { + const models = new Set(); + const recipes = new Set(); + for (const catalog of catalogs) { + for (const model of catalog.index.models) { + if (model.deprecated) continue; + const identity = [model.name, model.source.hf, model.source.revision ?? ""]; + models.add(JSON.stringify(identity)); + for (const version of model.versions) { + for (const variant of version.variants) { + recipes.add(JSON.stringify([...identity, version.version, version.digest, variant.id])); + } + } + } + } + return { models: models.size, recipes: recipes.size }; +} + +export function recordedTimeline(runs: Run[], from: string, to: string, hasStore: boolean): DeploymentTimeline { + const start = Date.parse(from); + const end = Date.parse(to); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) throw new Error("Invalid timeline range"); + const complete = runs.length < HISTORY_LIMIT; + const result: DeploymentTimeline = { hasStore, from, to, complete, buckets: [] }; + if (!hasStore || !complete) return result; + + const first = Math.floor(start / HOUR_MS) * HOUR_MS; + for (let hour = first; hour < end; hour += HOUR_MS) { + result.buckets.push({ at: new Date(hour).toISOString(), started: 0, updated: 0, failed: 0 }); + } + for (const run of runs) { + if (!["install", "apply", "rollback"].includes(run.action)) continue; + const at = Date.parse(run.startedAt); + if (!Number.isFinite(at)) throw new Error("Invalid deployment timestamp"); + if (at < start || at >= end) continue; + const bucket = result.buckets[Math.floor((at - first) / HOUR_MS)]; + if (run.error) bucket.failed++; + else if (run.changed && run.action === "install") bucket.started++; + else if (run.changed) bucket.updated++; + } + return result; +} + +export function timelineTotals(timeline: DeploymentTimeline) { + if (!timeline.hasStore || !timeline.complete) return null; + return timeline.buckets.reduce( + (sum, bucket) => ({ + started: sum.started + bucket.started, + updated: sum.updated + bucket.updated, + failed: sum.failed + bucket.failed, + }), + { started: 0, updated: 0, failed: 0 }, + ); +} + +export type HealthBucket = "serving" | "failed" | "degraded" | "unknown"; +export interface ServiceHealth { + serving: number; + failed: number; + degraded: number; + ready: number; + instances: number; + unknown: number; +} +type StatusSlot = { data?: ReleaseStatus; error?: unknown }; + +// The state a dashboard row shows: live status when it loaded, the list row +// otherwise, and unknown when the read itself failed. +export function currentState(d: Deployment, s?: StatusSlot): State { + if (s?.error) return { key: "unknown", tone: "muted" }; + return s?.data ? releaseState(s.data) : rowState(d); +} + +export function healthBucket(state: State): HealthBucket { + if (state.tone === "error") return "failed"; + if (state.key === "serving") return "serving"; + if (state.key === "loading" || state.key === "applying" || state.key === "noPods" || state.key === "uninstalling") return "degraded"; + return "unknown"; +} + +// Troubled first, so the preview surfaces what needs an operator. +export function healthRank(d: Deployment, s?: StatusSlot): number { + const bucket = healthBucket(currentState(d, s)); + return bucket === "failed" ? 0 : bucket === "degraded" ? 1 : bucket === "serving" ? 2 : 3; +} + +// Health counts assume every row's status read has settled (data or error): +// an unsettled read is unknown, never silently healthy or failed. +export function serviceHealth(rows: readonly Deployment[], statuses: readonly (StatusSlot | undefined)[]): ServiceHealth { + const out: ServiceHealth = { serving: 0, failed: 0, degraded: 0, ready: 0, instances: 0, unknown: 0 }; + rows.forEach((_, i) => { + const s = statuses[i]; + if (!s?.error && !s?.data) { + out.unknown++; + return; + } + out[healthBucket(currentState(rows[i], s))]++; + if (s.data) { + out.ready += s.data.ready; + out.instances += s.data.total; + } + }); + return out; +} diff --git a/web/src/modules/inferences/index.tsx b/web/src/modules/inferences/index.tsx index bc32de9..ce8cb69 100644 --- a/web/src/modules/inferences/index.tsx +++ b/web/src/modules/inferences/index.tsx @@ -3,6 +3,7 @@ import { Activity, Bot, Library, Server, SlidersHorizontal } from "lucide-react" import type { ConsoleModule } from "@/shell"; import { useT } from "@/modules/inferences/i18n"; import { SwissScope } from "@/modules/inferences/components/SwissScope"; +import { InferencesOverview } from "@/modules/inferences/Dashboard"; const InferenceList = lazy(() => import("@/modules/inferences/InferenceList").then((m) => ({ default: m.InferenceList }))); const CatalogPage = lazy(() => import("@/modules/inferences/CatalogPage").then((m) => ({ default: m.CatalogPage }))); @@ -31,10 +32,11 @@ export const inferencesModule: ConsoleModule = { title: "模型服务", basePath: "/inferences", frame: "flush", + overview: InferencesOverview, pages: [ { path: "", element: page(), permission: "swiss.view", menu: { label: "推理服务", icon: Bot } }, { path: ":release/details", element: page(), permission: "swiss.view" }, - { path: "catalog", element: page(), permission: "swiss.view", menu: { label: "模型库", icon: Library } }, + { path: "catalog", element: page(), permission: "swiss.view", menu: { label: "模型库", icon: Library }, topbar: { label: "模型广场" } }, { path: "catalog/:name", element: page(), permission: "swiss.view" }, { path: "nodes", element: page(), permission: "swiss.view", menu: { label: "节点", icon: Server } }, { path: "runs", element: page(), permission: "swiss.view", menu: { label: "操作记录", icon: Activity } }, diff --git a/web/src/modules/inferences/locales/en-US.json b/web/src/modules/inferences/locales/en-US.json index e1c78e5..7deafc4 100644 --- a/web/src/modules/inferences/locales/en-US.json +++ b/web/src/modules/inferences/locales/en-US.json @@ -1,10 +1,70 @@ { + "dashboard": { + "title": "Inference Overview", + "services": "Inference services", + "nodes": "Cluster nodes", + "gpus": "Total GPUs", + "gpusUsed": "Allocated GPUs", + "catalog": "Model library", + "activity": "Recent activity", + "instances": "Ready instances", + "serving": "Serving", + "failedServices": "Failed", + "changing": "Loading / changing", + "healthUnknown": "{n} service states unconfirmed; counts may be incomplete", + "capacity": "Capacity", + "preview": "Showing {shown} of {total} services", + "nodesError": "Unable to load node resources: {error}", + "statusError": "Status unavailable", + "retry": "Retry", + "supportedModels": "Supported models", + "recipeTotal": "Total recipes", + "catalogError": "Unable to load catalog statistics: {error}", + "trend": { + "title": "Model Deployment Activity", + "range": "Last 24 hours · Hourly", + "records": "Deployment log", + "started": "Deployments started", + "updated": "Updates / rollbacks", + "failed": "Failed operations", + "unit": " operations", + "chartLabel": "Hourly deployment starts, updates and failures over the last 24 hours", + "noHistory": "Deployment history is not enabled", + "incomplete": "The backend history limit was reached. The 24-hour window is incomplete." + }, + "cluster": { + "title": "Cluster Information", + "config": "Settings", + "name": "Cluster", + "kubernetes": "Node K8s", + "namespace": "Namespace", + "ready": "Ready nodes", + "schedulable": "Schedulable", + "backend": "swissd version", + "mode": "Deploy mode", + "deployEnabled": "Enabled", + "readOnly": "Read-only" + }, + "resources": { + "title": "Compute Resources", + "summary": "{nodes} nodes · {gpus} GPUs", + "byNode": "GPU allocation by node", + "unallocated": "Unallocated", + "allocation": "GPU allocation", + "online": "Nodes online", + "unknown": "Not measured", + "noNodes": "No GPU node data", + "freeCount": "{n} GPUs unallocated", + "readyCount": "{ready} / {total} nodes online" + } + }, "group": { "模型服务": "Model Serving" }, "menu": { "index": "Inference Services", "catalog": "Model Library", + "catalogTopbar": "Model Hub", "nodes": "Nodes", "runs": "Activity", "siteProfile": "Site Profile" diff --git a/web/src/modules/inferences/locales/zh-CN.json b/web/src/modules/inferences/locales/zh-CN.json index e5fcec8..4739ccd 100644 --- a/web/src/modules/inferences/locales/zh-CN.json +++ b/web/src/modules/inferences/locales/zh-CN.json @@ -1,4 +1,63 @@ { + "dashboard": { + "title": "推理运行概览", + "services": "推理服务", + "nodes": "集群节点", + "gpus": "GPU 总量", + "gpusUsed": "已分配 GPU", + "catalog": "模型库", + "activity": "最近操作", + "instances": "就绪实例", + "serving": "可用服务", + "failedServices": "异常服务", + "changing": "加载 / 变更中", + "healthUnknown": "有 {n} 个服务状态未确认,统计可能不完整", + "capacity": "算力概况", + "preview": "展示 {shown} / {total} 个服务", + "nodesError": "节点资源读取失败:{error}", + "statusError": "状态读取失败", + "retry": "重试", + "supportedModels": "支持模型", + "recipeTotal": "配方总数", + "catalogError": "模型能力统计读取失败:{error}", + "trend": { + "title": "模型部署动态", + "range": "过去 24 小时 · 按小时", + "records": "部署记录", + "started": "部署启动", + "updated": "更新 / 回滚", + "failed": "失败操作", + "unit": "次", + "chartLabel": "过去 24 小时部署启动、更新和失败操作的逐小时曲线", + "noHistory": "未启用部署历史记录", + "incomplete": "当前后端历史记录达到返回上限,24 小时数据不完整。" + }, + "cluster": { + "title": "集群信息", + "config": "配置", + "name": "集群名称", + "kubernetes": "K8s 节点版本", + "namespace": "命名空间", + "ready": "在线节点", + "schedulable": "可调度节点", + "backend": "swissd 版本", + "mode": "部署模式", + "deployEnabled": "允许部署", + "readOnly": "只读" + }, + "resources": { + "title": "算力资源", + "summary": "{nodes} 个节点 · {gpus} 个 GPU", + "byNode": "节点 GPU 分配", + "unallocated": "未分配", + "allocation": "GPU 分配率", + "online": "节点在线率", + "unknown": "未测量", + "noNodes": "暂无 GPU 节点数据", + "freeCount": "未分配 {n} 个 GPU", + "readyCount": "{ready} / {total} 个节点在线" + } + }, "list": { "title": "推理服务", "description": "swiss 部署的模型推理服务,对外提供 OpenAI 兼容端点。在模型库里选模型部署;升级、回滚和卸载在每个服务上操作。", diff --git a/web/src/modules/inferences/useSupportedTotals.ts b/web/src/modules/inferences/useSupportedTotals.ts new file mode 100644 index 0000000..9849c70 --- /dev/null +++ b/web/src/modules/inferences/useSupportedTotals.ts @@ -0,0 +1,27 @@ +import { useQueries, type UseQueryResult } from "@tanstack/react-query"; +import { api, type ClusterInfo } from "@swiss/lib/api"; +import { supportedTotals } from "@/modules/inferences/dashboard-lib"; + +export function useSupportedTotals(cluster: UseQueryResult) { + const catalogs = cluster.data?.catalogs ?? []; + const queries = useQueries({ + queries: catalogs.map((catalog) => ({ + queryKey: ["catalog", catalog.name], + queryFn: () => api.catalog(catalog.name), + staleTime: 60_000, + refetchInterval: 60_000, + })), + }); + const error = cluster.error ?? queries.find((q) => q.error)?.error ?? null; + const loading = cluster.isPending || queries.some((q) => q.isPending); + const complete = !!cluster.data && !loading && !error; + const totals = complete ? supportedTotals(queries.map((q) => q.data!)) : null; + const retry = () => { + if (cluster.error) void cluster.refetch(); + else + queries.forEach((q) => { + if (q.error) void q.refetch(); + }); + }; + return { totals, error, loading, retry }; +} diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index c5b1c0d..ac27236 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -35,7 +35,7 @@ export function Layout({ modules, children }: { modules: ConsoleModule[]; childr const { has } = usePermissions(); const t = useT("shell"); const layout = useLayout(); - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(() => typeof window !== "undefined" && window.innerWidth < 768); const [prefsOpen, setPrefsOpen] = useState(false); const groups = navGroups(modules, has); const links = topbarItems(modules, has); @@ -141,18 +141,19 @@ function TopBar({ )} - + - ModelSphere + ModelSphere
{links.map((item) => { const Icon = item.icon; + const label = navLabel(tr, item.ns, "menu", item.key, item.label); return ( - - - {navLabel(tr, item.ns, "menu", item.key, item.label)} + + + {label} ); })} @@ -171,7 +172,7 @@ function TopBar({ {initial} - {me?.name} + {me?.name} diff --git a/web/src/shell/index.ts b/web/src/shell/index.ts index ec6083e..07e0282 100644 --- a/web/src/shell/index.ts +++ b/web/src/shell/index.ts @@ -1,6 +1,6 @@ // Everything a module may import from the shell. Modules import "@/shell" only, // never a file under it, so the shell can move things around freely. -export type { ConsoleModule, ModulePage } from "@/shell/module"; +export type { ConsoleModule, ModulePage, ModuleOverviewProps } from "@/shell/module"; export { useModulePath, ModuleProvider } from "@/shell/module"; export { apiFetch, request, ApiError, type Me } from "@/shell/api"; export { useAuth } from "@/shell/auth"; diff --git a/web/src/shell/locales/shell.en-US.json b/web/src/shell/locales/shell.en-US.json index 86f7e0c..6474c5b 100644 --- a/web/src/shell/locales/shell.en-US.json +++ b/web/src/shell/locales/shell.en-US.json @@ -12,7 +12,7 @@ "logout": "Sign out" }, "home": { - "title": "Overview", + "title": "Inference Overview", "session": "Current session", "user": "User: ", "email": "Email: ", diff --git a/web/src/shell/locales/shell.zh-CN.json b/web/src/shell/locales/shell.zh-CN.json index 50bd2a1..7e19405 100644 --- a/web/src/shell/locales/shell.zh-CN.json +++ b/web/src/shell/locales/shell.zh-CN.json @@ -9,7 +9,7 @@ "logout": "退出登录" }, "home": { - "title": "概览", + "title": "推理运行概览", "session": "当前会话", "user": "用户:", "email": "邮箱:", diff --git a/web/src/shell/module.test.tsx b/web/src/shell/module.test.tsx index b848bf3..528e385 100644 --- a/web/src/shell/module.test.tsx +++ b/web/src/shell/module.test.tsx @@ -112,4 +112,20 @@ describe("topbarItems", () => { expect(topbarItems([mod(), help], () => false).map((i) => `${i.label}@${i.to}`)).toEqual(["使用文档@/help/docs"]); expect(topbarItems([mod(), help], () => true).map((i) => i.to)).toEqual(["/help/docs", "/help/admin"]); }); + + const serving = mod({ + id: "inferences", + basePath: "/inferences", + pages: [{ path: "catalog", element: null, permission: "swiss.view", menu: { label: "模型库", icon: Boxes }, topbar: { label: "模型广场" } }], + }); + + it("uses a top-bar label without changing the sidebar or page destination", () => { + const [item] = topbarItems([serving], () => true); + expect(item).toMatchObject({ label: "模型广场", to: "/inferences/catalog", ns: "inferences", key: "catalogTopbar" }); + expect(navGroups([serving], () => true)[1].items[0]).toMatchObject({ label: "模型库", to: item.to, key: "catalog" }); + }); + + it("hides the model plaza when the catalog permission is missing", () => { + expect(topbarItems([serving, help], () => false).map((i) => i.to)).toEqual(["/help/docs"]); + }); }); diff --git a/web/src/shell/module.tsx b/web/src/shell/module.tsx index f00e069..cf3029a 100644 --- a/web/src/shell/module.tsx +++ b/web/src/shell/module.tsx @@ -7,6 +7,10 @@ import { pageKey } from "@/shell/i18n"; // guards, the query client and the session on every request. // // Plugging a module in or out is one line in src/modules/index.ts. +export interface ModuleOverviewProps { + session?: ReactNode; +} + export interface ConsoleModule { // Stable id: used as the React key and in logs. Lowercase, no spaces. id: string; @@ -20,7 +24,7 @@ export interface ConsoleModule { // edge, then their own p-4 body. Default "padded": the shell insets the page. frame?: "padded" | "flush"; // Optional card(s) shown on the console home page. - overview?: ComponentType; + overview?: ComponentType; } export interface ModulePage { @@ -33,8 +37,8 @@ export interface ModulePage { // Present means the page gets a sidebar entry. label is Chinese; other // languages: "{module id}:menu.{pageKey(path)}". menu?: { label: string; icon: LucideIcon }; - // Also link the page from the top bar, with its menu label and icon. - topbar?: boolean; + // Also link the page from the top bar; an object overrides its menu label. + topbar?: boolean | { label: string }; } const ModuleContext = createContext(null); @@ -114,22 +118,25 @@ export function navGroups(modules: ConsoleModule[], has: (permission: string) => return groups; } -function menuItems(m: ConsoleModule, has: (permission: string) => boolean, pick: (p: ModulePage) => boolean = () => true): NavItem[] { +function menuItems(m: ConsoleModule, has: (permission: string) => boolean, pick: (p: ModulePage) => boolean = () => true, placement: "menu" | "topbar" = "menu"): NavItem[] { return m.pages .filter((p) => p.menu && (!p.permission || has(p.permission)) && pick(p)) - .map((p) => ({ - to: joinPath(m.basePath, p.path), - label: p.menu!.label, - ns: m.id, - key: pageKey(p.path), - icon: p.menu!.icon, - end: p.path === "", - })); + .map((p) => { + const topbar = placement === "topbar" && typeof p.topbar === "object" ? p.topbar : undefined; + return { + to: joinPath(m.basePath, p.path), + label: topbar?.label ?? p.menu!.label, + ns: m.id, + key: pageKey(p.path) + (topbar ? "Topbar" : ""), + icon: p.menu!.icon, + end: p.path === "", + }; + }); } // topbarItems are the pages declared topbar: true that the user may open. export function topbarItems(modules: ConsoleModule[], has: (permission: string) => boolean): NavItem[] { - return modules.flatMap((m) => menuItems(m, has, (p) => !!p.topbar)); + return modules.flatMap((m) => menuItems(m, has, (p) => !!p.topbar, "topbar")); } // activeGroupTitle is the group holding the current page: the module it is diff --git a/web/src/shell/pages/Home.health.test.tsx b/web/src/shell/pages/Home.health.test.tsx new file mode 100644 index 0000000..ba71363 --- /dev/null +++ b/web/src/shell/pages/Home.health.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Home } from "@/shell/pages/Home"; +import { PermissionsProvider } from "@/shell/permissions"; +import { inferencesModule } from "@/modules/inferences"; +import { DASHBOARD_PAGE_SIZE } from "@/modules/inferences/Dashboard"; +import { recordedTimeline } from "@/modules/inferences/dashboard-lib"; +import { DEFAULT_LOCALE, setLocale } from "@/shell/i18n"; +import type { CatalogResponse, ClusterInfo, Deployment, DeploymentsResponse, NodesResponse, ReleaseStatus, RunsResponse } from "@swiss/lib/api"; + +vi.mock("@/shell/auth", () => ({ useAuth: () => ({ me: { name: "tester", isAdmin: true, groups: [], permissions: ["*"] } }) })); + +const from = "2026-10-10T12:15:00Z"; +const to = "2026-10-11T12:15:00Z"; +const row = (release: string): Deployment => ({ release, namespace: "models", revision: 1, status: "deployed" }); +const status = (release: string, ready: number, total: number, extra: Partial = {}): ReleaseStatus => ({ release, namespace: "models", exists: true, revision: 1, pods: [], ready, total, ...extra }); +const nodes: NodesResponse = { cluster: "c", nodes: [], summary: { nodes: 1, gpus: 1, gpusUsed: 0 } }; +const cluster: ClusterInfo = { name: "c", profile: "p", catalog: "u", version: "0", allowDeploy: true, catalogs: [{ name: "default", url: "u", source: "u", default: true }] }; +const catalog: CatalogResponse = { name: "default", ref: "r", source: "u", index: { apiVersion: "catalog.swiss/v1", count: 0, models: [] } }; + +function client() { + return new QueryClient({ defaultOptions: { queries: { retry: false, retryOnMount: false } } }); +} +function seed(c: QueryClient, deployments: Deployment[]) { + c.setQueryData(["cluster"], cluster); + c.setQueryData(["catalog", "default"], catalog); + c.setQueryData(["deployments", "dashboard", "all"], { cluster: "c", catalogRef: "r", deployments, page: 1, perPage: 100, summary: { total: deployments.length, catalogBehind: 0 } }); + c.setQueryData(["nodes"], nodes); + c.setQueryData(["runs", "dashboard", "timeline"], recordedTimeline([], from, to, true)); + c.setQueryData(["runs", "dashboard"], { hasStore: true, runs: [] }); +} +function render(c: QueryClient) { + return renderToStaticMarkup( + + + + + + + , + ); +} +function fail(c: QueryClient, key: readonly unknown[], message: string) { + c.getQueryCache().build(c, { queryKey: key }).setState({ status: "error", fetchStatus: "idle", error: new Error(message) }); +} +afterEach(() => setLocale(DEFAULT_LOCALE)); + +describe("home service health", () => { + it("counts every service's live state, not only the visible rows", () => { + const c = client(); + const deployments = ["ok-a", "ok-b", "bad", "loading"].map(row); + seed(c, deployments); + c.setQueryData(["status", "models", "ok-a"], status("ok-a", 2, 2)); + c.setQueryData(["status", "models", "ok-b"], status("ok-b", 2, 2)); + c.setQueryData(["status", "models", "bad"], status("bad", 0, 1, { helmStatus: "failed" })); + c.setQueryData(["status", "models", "loading"], status("loading", 0, 2)); + const html = render(c); + expect(html).toContain('aria-label="可用服务: 2"'); + expect(html).toContain('aria-label="异常服务: 1"'); + expect(html).toContain('aria-label="加载 / 变更中: 1"'); + expect(html).toContain('aria-label="就绪实例: 4 / 7"'); + }); + + it("lists troubled services before healthy ones in the preview", () => { + const c = client(); + seed(c, ["ok-a", "bad", "loading"].map(row)); + c.setQueryData(["status", "models", "ok-a"], status("ok-a", 1, 1)); + c.setQueryData(["status", "models", "bad"], status("bad", 0, 1, { helmStatus: "failed" })); + c.setQueryData(["status", "models", "loading"], status("loading", 0, 1)); + const html = render(c); + expect(html.indexOf(">bad<")).toBeLessThan(html.indexOf(">loading<")); + expect(html.indexOf(">loading<")).toBeLessThan(html.indexOf(">ok-a<")); + }); + + it("warns instead of guessing when a service state cannot be read", () => { + const c = client(); + seed(c, ["ok-a", "bad"].map(row)); + c.setQueryData(["status", "models", "ok-a"], status("ok-a", 1, 1)); + fail(c, ["status", "models", "bad"], "status-forbidden"); + const html = render(c); + expect(html).toContain("有 1 个服务状态未确认"); + expect(html).toContain("重试"); + expect(html).not.toContain('aria-label="异常服务: 1"'); + }); + + it("shows the deployment trend in the left column, above the service list", () => { + const c = client(); + seed(c, []); + const html = render(c); + const trend = html.indexOf("模型部署动态"); + expect(trend).toBeGreaterThan(-1); + expect(trend).toBeLessThan(html.indexOf('aria-label="推理服务"')); + expect(trend).toBeLessThan(html.indexOf(" { + const c = client(); + seed(c, []); + const html = render(c); + const asideStart = html.indexOf("", asideStart)); + const services = html.indexOf('aria-label="推理服务"'); + const resources = html.indexOf('aria-label="算力资源"'); + const activity = html.indexOf('aria-label="最近操作"'); + expect(resources).toBeGreaterThan(services); + expect(activity).toBeGreaterThan(resources); + expect(activity).toBeLessThan(asideStart); + expect(aside.includes("集群信息")).toBe(true); + expect(aside.indexOf("当前会话")).toBeGreaterThan(aside.indexOf("集群信息")); + expect(aside.includes('aria-label="算力资源"')).toBe(false); + expect(aside.includes('aria-label="最近操作"')).toBe(false); + expect(html.match(/]*>当前会话<\/summary>/g)).toHaveLength(1); + }); + + it(`keeps the preview bounded at ${DASHBOARD_PAGE_SIZE} rows`, () => { + const c = client(); + seed(c, Array.from({ length: 9 }, (_, i) => row(`svc-${i}`))); + expect(render(c)).toContain("展示 6 / 9 个服务"); + }); +}); diff --git a/web/src/shell/pages/Home.test.tsx b/web/src/shell/pages/Home.test.tsx new file mode 100644 index 0000000..f32cc8a --- /dev/null +++ b/web/src/shell/pages/Home.test.tsx @@ -0,0 +1,350 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Home } from "@/shell/pages/Home"; +import { PermissionsProvider } from "@/shell/permissions"; +import { inferencesModule } from "@/modules/inferences"; +import { DASHBOARD_PAGE_SIZE } from "@/modules/inferences/Dashboard"; +import { recordedTimeline } from "@/modules/inferences/dashboard-lib"; +import { DEFAULT_LOCALE, setLocale } from "@/shell/i18n"; +import type { + CatalogResponse, + ClusterInfo, + Deployment, + DeploymentsResponse, + NodesResponse, + ReleaseStatus, + RunsResponse, +} from "@swiss/lib/api"; + +const auth = vi.hoisted(() => ({ permissions: ["*"] })); +vi.mock("@/shell/auth", () => ({ + useAuth: () => ({ me: { name: "tester", isAdmin: true, groups: [], permissions: auth.permissions } }), +})); +const clients: QueryClient[] = []; +const deploymentsKey = ["deployments", "dashboard", "all"]; +const historyKey = ["runs", "dashboard", "timeline"]; +const deployment: Deployment = { + release: "qwen", + namespace: "models", + model: "Qwen3", + version: "1", + route: "qwen", + revision: 2, + status: "deployed", +}; +const live: ReleaseStatus = { + release: "qwen", + namespace: "models", + exists: true, + revision: 2, + helmStatus: "deployed", + pods: [], + ready: 1, + total: 2, +}; +const nodes: NodesResponse = { + cluster: "test-cluster", + nodes: [ + { + Name: "worker", + GPUProduct: "H100", + GPUs: 8, + Ready: true, + Schedulable: true, + Kubelet: "v1.30.12", + gpusUsed: 4, + gpusFree: 4, + }, + ], + summary: { nodes: 1, gpus: 8, gpusUsed: 4 }, +}; +const cluster: ClusterInfo = { + name: "test-cluster", + profile: "site.yaml", + namespace: "models", + catalog: "https://catalog.example", + version: "0.6.3", + allowDeploy: true, + catalogs: [ + { name: "default", url: "https://catalog.example", source: "https://catalog.example/index.json", default: true }, + ], +}; +const catalog: CatalogResponse = { + name: "default", + ref: "main", + source: "https://catalog.example", + index: { + apiVersion: "catalog.swiss/v1", + count: 1, + models: [ + { + name: "alpha", + displayName: "Alpha Model", + source: { hf: "org/alpha" }, + latest: "2", + versions: [ + { + version: "1", + path: "alpha-1.yaml", + digest: "old", + variants: [{ id: "base", engine: "vllm", chart: { name: "vllm", version: "1" }, requires: { gpus: 1 } }], + }, + { + version: "2", + path: "alpha-2.yaml", + digest: "new", + variants: [ + { id: "base", engine: "vllm", chart: { name: "vllm", version: "2" }, requires: { gpus: 1 } }, + { id: "tuned", engine: "sglang", chart: { name: "sglang", version: "2" }, requires: { gpus: 2 } }, + ], + }, + ], + }, + ], + }, +}; +const history = recordedTimeline([], "2026-10-10T12:15:00Z", "2026-10-11T12:15:00Z", true); + +function client() { + const c = new QueryClient({ defaultOptions: { queries: { retry: false, retryOnMount: false } } }); + clients.push(c); + return c; +} +function seed(c: QueryClient, deployments: Deployment[] = [deployment]) { + c.setQueryData(["cluster"], cluster); + c.setQueryData(["catalog", "default"], catalog); + c.setQueryData(deploymentsKey, { + cluster: "test-cluster", + catalogRef: "main", + deployments, + page: 1, + perPage: DASHBOARD_PAGE_SIZE, + summary: { total: deployments.length ? 42 : 0, catalogBehind: 0 }, + }); + c.setQueryData(["nodes"], nodes); + c.setQueryData(["status", "models", "qwen"], live); + c.setQueryData(historyKey, history); + c.setQueryData(["runs", "dashboard"], { + hasStore: true, + runs: [ + { + id: 1, + namespace: "models", + release: "qwen", + action: "apply", + planHash: "hash", + changed: true, + startedAt: "2026-10-11T12:00:00Z", + endedAt: "2026-10-11T12:01:00Z", + }, + ], + }); +} +function render(c: QueryClient, basePath = inferencesModule.basePath) { + return renderToStaticMarkup( + + + + + + + , + ); +} +function fail(c: QueryClient, key: readonly unknown[], message: string) { + c.getQueryCache() + .build(c, { queryKey: key }) + .setState({ status: "error", fetchStatus: "idle", error: new Error(message) }); +} +afterEach(() => { + setLocale(DEFAULT_LOCALE); + auth.permissions = ["*"]; + clients.splice(0).forEach((c) => c.clear()); +}); + +describe("model operations dashboard", () => { + it("shows prominent supported model and full recipe totals without arbitrary previews", () => { + const c = client(); + seed(c); + const html = render(c); + expect(html).toContain('aria-label="支持模型: 1"'); + expect(html).toContain('aria-label="配方总数: 3"'); + expect(html).not.toContain("Alpha Model"); + expect(html).not.toContain("模型展示"); + expect(html).not.toContain("max-w-[1600px]"); + }); + it("counts all configured catalogs even when none is marked default", () => { + const c = client(); + seed(c); + c.setQueryData(["cluster"], { + ...cluster, + catalogs: [ + { name: "first", url: "first", source: "first" }, + { name: "second", url: "second", source: "second" }, + ], + }); + c.setQueryData(["catalog", "first"], catalog); + c.setQueryData(["catalog", "second"], { + ...catalog, + index: { + ...catalog.index, + models: catalog.index.models.map((m) => ({ ...m, name: "beta", source: { hf: "org/beta" } })), + }, + }); + const html = render(c); + expect(html).toContain('aria-label="支持模型: 2"'); + expect(html).toContain('aria-label="配方总数: 6"'); + }); + it("does not present partial catalog results as a complete capability total", () => { + const c = client(); + seed(c); + c.setQueryData(["cluster"], { + ...cluster, + catalogs: [...cluster.catalogs!, { name: "broken", url: "broken", source: "broken" }], + }); + fail(c, ["catalog", "broken"], "catalog-read-failed"); + const html = render(c); + expect(html).toContain('aria-label="支持模型: -"'); + expect(html).toContain('aria-label="配方总数: -"'); + expect(html).toContain("catalog-read-failed"); + expect(html).toContain("重试"); + }); + it("shows an operational 24-hour trend separately from the current service snapshot", () => { + const c = client(); + seed(c); + c.setQueryData(historyKey, { + ...history, + buckets: history.buckets.map((b, i) => ({ ...b, started: i === 2 ? 7 : 0, updated: i === 3 ? 2 : 0 })), + }); + const html = render(c); + expect(html).toContain("模型部署动态"); + expect(html).toContain("过去 24 小时"); + expect(html).toContain("部署启动"); + expect(html).toContain("更新 / 回滚"); + expect(html).toContain('role="img"'); + expect(html).toContain("展示 1 / 42 个服务"); + }); + it("does not fake a zero time series when history persistence is unavailable", () => { + const c = client(); + seed(c); + c.setQueryData(historyKey, { ...history, hasStore: false, buckets: [] }); + const html = render(c); + expect(html).toContain("未启用部署历史记录"); + expect(html).not.toContain('aria-label="过去 24 小时部署启动'); + }); + it("does not draw a complete graph from capped legacy history", () => { + const c = client(); + seed(c); + c.setQueryData(historyKey, { ...history, complete: false, buckets: [] }); + const html = render(c); + expect(html).toContain("24 小时数据不完整"); + expect(html).not.toContain('aria-label="过去 24 小时部署启动'); + }); + it("keeps chart errors local and retryable", () => { + const c = client(); + seed(c); + fail(c, historyKey, "timeline-read-failed"); + const html = render(c); + expect(html).toContain("timeline-read-failed"); + expect(html).toContain("重试"); + expect(html).toContain("Qwen3 v1"); + }); + it("uses live instance readiness rather than just deployed Helm status", () => { + const c = client(); + seed(c); + const html = render(c); + expect(html).toContain("1 / 2"); + expect(html).toContain("加载中"); + expect(html).not.toContain("运行中"); + c.setQueryData(["status", "models", "qwen"], { ...live, ready: 2 }); + expect(render(c)).toContain("运行中"); + }); + it("keeps node GPU usage unknown on insufficient pod-list permissions", () => { + const c = client(); + seed(c); + c.setQueryData(["nodes"], { + ...nodes, + usageError: "pod-list-forbidden", + summary: { ...nodes.summary, gpusUsed: 12345 }, + }); + const html = render(c); + expect(html).toContain("未测量"); + expect(html).toContain("pod-list-forbidden"); + expect(html).not.toContain("12345"); + }); + it("preserves empty service deployment entry and recent operation links", () => { + const c = client(); + seed(c, []); + const html = render(c); + expect(html).toContain("还没有推理服务"); + expect(html).toContain("部署模型"); + expect(html).toContain('href="/inferences/catalog"'); + expect(html).toContain("tab=runs"); + }); + it("does not restore the removed global refresh control", () => { + const c = client(); + seed(c); + const html = render(c); + expect(html).not.toContain('aria-label="刷新"'); + expect(html).not.toContain('title="刷新"'); + }); + it("retains boxed surfaces and collapsed session information", () => { + const c = client(); + seed(c); + const html = render(c); + const panels = ["模型部署动态", "算力概况", "推理服务", "集群信息", "算力资源", "最近操作"]; + for (const label of panels) { + expect(html.match(new RegExp(`]*aria-label="${label}"`, "g"))).toHaveLength(1); + } + expect(html.match(/overflow-hidden rounded-lg border border-border bg-card/g)).toHaveLength(panels.length); + expect(html).toContain('class="rounded-lg border border-border bg-card p-4 text-sm"'); + expect(html).not.toContain("
{ + const c = client(); + seed(c); + const html = render(c); + expect(html).toContain("2xl:grid-cols-[minmax(0,1fr)_minmax(360px,20%)]"); + expect(html).toContain("min-[120rem]:grid-cols-[minmax(0,1fr)_minmax(320px,30%)]"); + expect(html).toContain('class="min-w-0 h-full" aria-label="算力概况"'); + expect(html).toContain("flex flex-1 items-center justify-center"); + }); + it("keeps Kubernetes node versions distinct from the inference backend version", () => { + const c = client(); + seed(c); + const html = render(c); + expect(html).toContain("K8s 节点版本"); + expect(html).toContain("v1.30.12"); + expect(html).toContain("swissd 版本"); + expect(html).toContain("0.6.3"); + }); + it("retains the session when no overview modules are installed", () => { + const html = renderToStaticMarkup(); + expect(html.match(/]*>当前会话<\/summary>/g)).toHaveLength(1); + expect(html).toContain("tester"); + expect(html).not.toContain("
{ + auth.permissions = []; + const c = client(); + const html = render(c); + expect(html).not.toContain('aria-label="推理运行概览"'); + expect(html).toContain("当前会话"); + expect(html).toContain("tester"); + expect(c.getQueryCache().getAll()).toHaveLength(0); + }); + it("keeps all navigation under the mounted module and translates to English", () => { + setLocale("en-US"); + const c = client(); + seed(c); + const html = render(c, "/serving"); + expect(html).toContain("Supported models"); + expect(html).toContain("Total recipes"); + expect(html).toContain("Model Deployment Activity"); + expect(html).toContain('href="/serving/catalog"'); + expect(html).toContain('href="/serving/qwen/details?namespace=models"'); + expect(html).not.toContain("dashboard."); + }); +}); diff --git a/web/src/shell/pages/Home.tsx b/web/src/shell/pages/Home.tsx index df690bc..b77ed24 100644 --- a/web/src/shell/pages/Home.tsx +++ b/web/src/shell/pages/Home.tsx @@ -1,4 +1,4 @@ -import { Card, CardContent, CardHeader, CardTitle, PageBanner } from "@modelsphere/ui"; +import { PageBanner } from "@modelsphere/ui"; import { LayoutDashboard } from "lucide-react"; import { useAuth } from "@/shell/auth"; import { ModuleProvider, type ConsoleModule } from "@/shell/module"; @@ -7,47 +7,48 @@ import { useT } from "@/shell/i18n"; export function Home({ modules }: { modules: ConsoleModule[] }) { const { me } = useAuth(); const t = useT("shell"); + const overviews = modules.filter((m) => m.overview); + const session = ( +
+ {t("home.session")} +
+
+ {t("home.user")} + {me?.name} +
+ {me?.email && ( +
+ {t("home.email")} + {me.email} +
+ )} +
+ {t("home.groups")} + {me?.groups?.join(", ")} +
+
+ {t("home.role")} + {t(me?.isAdmin ? "home.admin" : "home.member")} +
+
+
+ ); return (
} /> -
-
- {modules.map((m) => { - const Overview = m.overview; - return Overview ? ( +
+
+ {overviews.map((m, i) => { + const Overview = m.overview!; + return ( - + - ) : null; + ); })}
- - - - {t("home.session")} - - -
- {t("home.user")} - {me?.name} -
- {me?.email && ( -
- {t("home.email")} - {me.email} -
- )} -
- {t("home.groups")} - {me?.groups?.join(", ")} -
-
- {t("home.role")} - {t(me?.isAdmin ? "home.admin" : "home.member")} -
-
-
+ {overviews.length === 0 && session}
);