diff --git a/api/internal/auth/perms.go b/api/internal/auth/perms.go index 1a6cd22dc..b7aa9eb5a 100644 --- a/api/internal/auth/perms.go +++ b/api/internal/auth/perms.go @@ -158,7 +158,7 @@ func (u *User) Can(perm string, namespaced bool, cluster, ns string) bool { } // CanDiscoverCluster permits selecting a cluster without granting node inventory -// access. A server reader in one namespace still needs to find that cluster. +// access. Server and backup-destination readers still need to find their cluster. func (u *User) CanDiscoverCluster(cluster string) bool { if u == nil { return false @@ -171,9 +171,14 @@ func (u *User) CanDiscoverCluster(cluster string) bool { if u.Can("cluster:read", true, cluster, "") || u.Can("modules:read", true, cluster, "") || u.Can("modules:manage", true, cluster, "") { return true } + // Install-time storage is now displayed in the local cluster's configuration. + // A central configuration grant must not reveal remote registrations. + if cluster == scope.DefaultCluster && (u.Can("config:read", false, "", "") || u.Can("config:manage", false, "", "")) { + return true + } for _, ck := range []string{cluster, "*"} { for _, perms := range u.Perms[ck] { - if permSetHas(perms, "*") || permSetHas(perms, "servers:read") { + if permSetHas(perms, "*") || permSetHas(perms, "servers:read") || permSetHas(perms, "destinations:read") || permSetHas(perms, "destinations:manage") { return true } } diff --git a/api/internal/handlers/clusters_discovery_test.go b/api/internal/handlers/clusters_discovery_test.go index 462b27b94..867486949 100644 --- a/api/internal/handlers/clusters_discovery_test.go +++ b/api/internal/handlers/clusters_discovery_test.go @@ -26,6 +26,11 @@ func TestClusterDiscovery_FiltersRegistrationsAndInventoryCapability(t *testing. {"local reader", "local", "*", "cluster:read", []string{"local"}, true}, {"remote reader", "remote", "*", "cluster:read", []string{"remote"}, true}, {"remote server operator", "remote", "gameplane-games", "servers:read", []string{"remote"}, false}, + {"remote destination reader", "remote", "gameplane-games", "destinations:read", []string{"remote"}, false}, + {"remote destination manager", "remote", "gameplane-games", "destinations:manage", []string{"remote"}, false}, + {"wildcard destination reader", "*", "gameplane-games", "destinations:read", []string{"local", "hidden", "remote"}, false}, + {"local configuration reader", "local", "*", "config:read", []string{"local"}, false}, + {"panel configuration manager", "remote", "*", "config:manage", []string{"local"}, false}, {"namespace admin", "remote", "gameplane-games", "*", []string{"remote"}, false}, {"unrelated permission", "remote", "gameplane-games", "backups:read", nil, false}, {"namespace inventory is insufficient", "remote", "gameplane-games", "cluster:read", nil, false}, diff --git a/assets/design-export/MANIFEST.md b/assets/design-export/MANIFEST.md index 16db3e450..b7e0ccd34 100644 --- a/assets/design-export/MANIFEST.md +++ b/assets/design-export/MANIFEST.md @@ -2825,3 +2825,52 @@ Source: `web/src/components/backups/BackupDetailDrawer.tsx`. The recorded state ### Follow-up alignment — 2026-11-04 Re-exported `zhLZN` and `bHJy6` from Pencil after reviewing the CI captures. The drawer now explicitly removes HeroUI body/footer margins and uses foreground text for values, matching the design's 20px content inset. The recorded reference is 400px wide (440px drawer minus two 20px insets); native Windows scrollbars consume additional space in the live scroll area. Coverage cards remain 98px tall. Header/footer heights, field line heights, the phase chip, and action-button typography now match the component styles. Both nodes were visually inspected in Pencil and the recorded state was inspected in Chrome; no comparison thresholds were changed. JSON and 2× PNG exports use the same methods above. + + +## Incremental export 2026-11-09 — Clusters and all-cluster Nodes navigation + +The canonical infrastructure navigation is **Clusters** plus **Nodes**. Nodes lists inventory across authorized clusters with search, cluster/status filters, sortable columns, explicit partial coverage, and unknown metric values. Cluster details reuse that inventory table with a fixed cluster scope and separate Overview, Nodes, and Configuration tabs. + +| Node | Name | Change | +| --- | --- | --- | +| `BdmAW` | Screen/Nodes — All clusters | New desktop inventory: readiness and reporting totals, CPU/memory coverage, filters, partial-inventory notice, shared sortable table, and Add node. | +| `I38x1` | Gameplane/Node Inventory Table | New reusable table composed from existing HeroUI column/cell primitives. Columns: Node, Cluster, Status, CPU, Memory, Pods, Uptime. | +| `wqnM9` | Screen/Cluster Details — Overview | New registration identity, API connectivity, node readiness, Kubernetes version, and workload/inventory links. | +| `J8GzM9` | Screen/Cluster Details — Nodes | New fixed-cluster variant of the same inventory table, with search/status filtering and View all nodes. | +| `OecJ5` | Screen/Cluster Details — Configuration — Local | New local access/download, read-only storage class, and scoped backup destinations. Built-in local registration cannot be removed. | +| `T70dJ` | Screen/Cluster Details — Configuration — Default storage | New empty-storage-class variant; new volumes use the cluster default. | +| `t56aLu` | Screen/Cluster Details — Configuration — Remote | New remote-managed access/storage state, scoped backup destinations, and separated registration-removal section. | +| `IBUqJ` | Screen/Nodes — Add node target | New explicit target-cluster selection before enrollment. Remote management and local operations availability remain capability dependent. | +| `N2nTf` | Screen/Mobile — Nodes — All clusters | New 390px-wide stacked rows with search, cluster/status filters, sorting, readiness, and partial coverage. | +| `kKFX9` | Gameplane/App Sidebar | Added Nodes (`VqyiS`) after Clusters. | +| `Mp7Ep` | Gameplane/Admin Settings Nav | Removed Backup destinations; it now belongs to cluster Configuration. | +| `iYUWc`, `WrmU5` | Registered cluster lists | Open cluster plus View nodes entry points; registration removal is in detail Configuration. | +| `Y96CaE` | Gameplane/Cluster Resources Card | View nodes opens `/nodes` while preserving the Dashboard cluster filter. Canonical dashboard instances updated. | +| `FyV6E`, `rCQaM` | Shared fleet filters | Cluster and All clusters terminology. | + +The previous mixed Cluster Settings screens (`j9W8A`, `dxdEi`), Admin backup-destination screens (`RC3Kf`, `D9QYZ`), standalone inventory-selection screen (`Be7ZW`), and list-based removal screen (`HVhmb`) are retained under **Legacy/** names with explicit supersession context. Their old storage and form states remain available as historical reference. + +Changed 51 product-copy entries from location/locations to cluster/clusters across fleet screens, wizard selection, central-management explanations, and reusable filters. Technical identifiers and URLs were not renamed. + +**Export coverage:** 54 objects have refreshed `json/.json` and 2× `screenshots/.png`: + +`kKFX9`, `Mp7Ep`, `I38x1`, `BdmAW`, `wqnM9`, `J8GzM9`, `OecJ5`, `t56aLu`, `IBUqJ`, `N2nTf`, `iYUWc`, `WrmU5`, `j9W8A`, `dxdEi`, `RC3Kf`, `D9QYZ`, `Be7ZW`, `HVhmb`, `T70dJ`, `j24cXg`, `W8idqY`, `nNL3E`, `vUqMl`, `f1Vga`, `UMJli`, `kK8Ji`, `DPrYX`, `fK8Bi`, `tTSdi`, `uMiwd`, `Wj0V4`, `zFiOW`, `F9pUrx`, `oyoTs`, `QQtUD`, `UCNBm`, `pnAbF`, `Cb3eC`, `fbBqA`, `YPKbV`, `NKiqf`, `zj5a3`, `GR1G9`, `n2qdi`, `RpHBg`, `l64bd`, `Uzzje`, `FOVMf`, `r139UP`, `w34N9`, `FyV6E`, `rCQaM`, `NLu2I`, `Y96CaE`. + +**Method and verification:** Edited only through Pencil MCP. JSON came from `Get` at depth 9 with targeted expansion of elided child trees; exports retain component references, with referenced changed components exported separately. PNG came from `Export(..., "png", ..., {scale: 2})`. All 54 JSON files parse, match their root IDs, have no elided children or placeholder flags, and have nonempty PNGs with valid dimensions. Existing CRLF line endings were preserved for 23 JSON files. Canonical desktop, cluster-detail, enrollment-dialog, mobile, dashboard, and representative filter layouts were visually checked with `TakeScreenshot`. The document visitor's known descendant-walk error was avoided by enumerating top-level roots with `skipChildren()` and reading each tree directly. No application tests or lint suites were run. + +### Nodes search and filter controls — 2026-11-09 + +Nodes now follows the Backups toolbar: a plain search input, capped at 320px on desktop and flexible on mobile, plus one right-aligned **Filter** trigger. Cluster and Status move into the existing Clear/Apply popover pattern. The count badge reflects applied Cluster and Status filters only, excludes search, and is hidden at zero. Opening copies applied values into drafts; Clear resets drafts; Apply commits and closes; dismissing discards drafts. Within cluster details, only Status is editable and counted; the fixed cluster remains visible as read-only context. + +| Node | Exported change | +| --- | --- | +| `BdmAW` | Desktop Nodes uses the compact search and Filter row. | +| `N2nTf` | Mobile Nodes uses the same controls on one row; sorting and stacked node cards remain below. | +| `J8GzM9` | Cluster-scoped Nodes keeps its fixed-cluster label beside the Status-only Filter trigger. | +| `IBUqJ` | The enrollment design's background inventory toolbar uses the same shared row. | +| `b2JF52` | New reusable Nodes Search and Filters Row, with optional read-only cluster scope. | +| `xrNf3` | New reusable Nodes Filters Button; its component preview demonstrates the two-active-filter badge. | +| `xjVA4` | New reusable Nodes Filters Popover, adapted from Backups with Cluster and Status fields. | + +All seven roots have JSON from Pencil `Get` and 2× PNG from `Export`. Toolbar, popover, desktop, fixed-cluster and mobile layouts were inspected using `TakeScreenshot`; the full screens retain their previous dimensions and content. JSON exports retain component references without elided children or editing placeholders. No React source, tests or lint suites were changed or run for this design update. + diff --git a/assets/design-export/json/BdmAW.json b/assets/design-export/json/BdmAW.json new file mode 100644 index 000000000..bd2e6948e --- /dev/null +++ b/assets/design-export/json/BdmAW.json @@ -0,0 +1,251 @@ +{ + "type": "frame", + "id": "BdmAW", + "x": 11480, + "y": 63932, + "name": "Screen/Nodes — All clusters", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "grYDZ", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "VqyiS": { + "fill": "$accent/soft" + }, + "VqyiS/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "VqyiS/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "gVOTp", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "r4xdRg", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Nodes" + } + } + }, + { + "type": "frame", + "id": "q7GH0", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "ofhIo", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Nodes" + }, + "VbHwU": { + "content": "Health and capacity across your authorized clusters." + }, + "TgdLz": { + "id": "kEFDn", + "type": "ref", + "ref": "cb4rt", + "name": "Add node", + "descendants": { + "U6q13B": { + "content": "Add node" + } + } + } + } + }, + { + "type": "frame", + "id": "F9AoB3", + "name": "Node inventory totals", + "width": "fill_container", + "gap": 12, + "children": [ + { + "id": "eMCSg", + "type": "ref", + "ref": "ZWcwn", + "name": "NODES READY", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "NODES READY" + }, + "QdcEm": { + "icon": "server" + }, + "vj2Ru": { + "content": "4 / 5" + }, + "h0n7zo": { + "content": "returned nodes" + } + } + }, + { + "id": "Xs472", + "type": "ref", + "ref": "ZWcwn", + "name": "CLUSTERS REPORTING", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "CLUSTERS REPORTING" + }, + "QdcEm": { + "icon": "network" + }, + "vj2Ru": { + "content": "2 / 3" + }, + "h0n7zo": { + "content": "one cluster unavailable" + } + } + }, + { + "id": "OOeYI", + "type": "ref", + "ref": "ZWcwn", + "name": "CPU USAGE", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "CPU USAGE" + }, + "QdcEm": { + "icon": "cpu" + }, + "vj2Ru": { + "content": "32%" + }, + "h0n7zo": { + "content": "4 nodes reporting" + } + } + }, + { + "id": "Jk6tu", + "type": "ref", + "ref": "ZWcwn", + "name": "MEMORY USAGE", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "MEMORY USAGE" + }, + "QdcEm": { + "icon": "memory-stick" + }, + "vj2Ru": { + "content": "43%" + }, + "h0n7zo": { + "content": "4 nodes reporting" + } + } + } + ] + }, + { + "id": "f42Iv", + "type": "ref", + "ref": "b2JF52", + "name": "Search and Filters", + "width": "fill_container" + }, + { + "type": "frame", + "id": "otKLx", + "name": "Partial inventory notice", + "width": "fill_container", + "fill": "$warning/soft", + "cornerRadius": 12, + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "y4htvj", + "name": "Coverage warning", + "fill": "$warning/soft-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Partial inventory · West is unavailable. Showing 5 returned nodes from 2 clusters.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "oIZu6", + "type": "ref", + "ref": "I38x1", + "name": "All authorized nodes", + "width": "fill_container" + }, + { + "type": "text", + "id": "dpgR1", + "name": "Inventory count", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5 nodes shown · Select a cluster name to open its details.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/Be7ZW.json b/assets/design-export/json/Be7ZW.json index c111fa6e0..588d6bea0 100644 --- a/assets/design-export/json/Be7ZW.json +++ b/assets/design-export/json/Be7ZW.json @@ -3,8 +3,8 @@ "id": "Be7ZW", "x": 4920, "y": 63932, - "name": "Screen/Standalone — Cluster inventory (No selection)", - "context": "PR #630. Selection is explicit and only registered remotes are listed. No implicit local target; no workload requests until installation and selected target are known.", + "name": "Legacy/Standalone — Cluster inventory selection — Superseded", + "context": "Superseded by Cluster Details Overview, Nodes and Configuration. Backup destinations and registration removal are scoped to cluster Configuration.", "theme": { "semantic": "dark" }, @@ -175,4 +175,3 @@ } ] } - diff --git a/assets/design-export/json/Cb3eC.json b/assets/design-export/json/Cb3eC.json index 56794339b..a66e09669 100644 --- a/assets/design-export/json/Cb3eC.json +++ b/assets/design-export/json/Cb3eC.json @@ -146,7 +146,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", diff --git a/assets/design-export/json/D9QYZ.json b/assets/design-export/json/D9QYZ.json index 4f4fd8fd5..7b5ddd38b 100644 --- a/assets/design-export/json/D9QYZ.json +++ b/assets/design-export/json/D9QYZ.json @@ -3,8 +3,8 @@ "id": "D9QYZ", "x": 1640, "y": 82231, - "name": "Screen/Standalone — Settings · Backup destinations", - "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "name": "Legacy/Standalone — Backup destinations — Moved", + "context": "Superseded by Cluster Details Overview, Nodes and Configuration. Backup destinations and registration removal are scoped to cluster Configuration.", "theme": { "semantic": "dark" }, @@ -85,7 +85,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -143,15 +143,6 @@ }, "DpMnt": { "fill": "$muted" - }, - "fOAMd": { - "fill": "$surface/secondary" - }, - "TWq2N": { - "fill": "$foreground/foreground" - }, - "E3ECJ": { - "fill": "$foreground/foreground" } } }, diff --git a/assets/design-export/json/DPrYX.json b/assets/design-export/json/DPrYX.json index f2195ae3d..b21abe449 100644 --- a/assets/design-export/json/DPrYX.json +++ b/assets/design-export/json/DPrYX.json @@ -82,7 +82,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", diff --git a/assets/design-export/json/F9pUrx.json b/assets/design-export/json/F9pUrx.json index f6076de64..750b96c8c 100644 --- a/assets/design-export/json/F9pUrx.json +++ b/assets/design-export/json/F9pUrx.json @@ -114,7 +114,7 @@ "content": "Servers" }, "VbHwU": { - "content": "Manage your game servers across all authorized locations." + "content": "Manage your game servers across all authorized clusters." }, "TgdLz": { "children": [ diff --git a/assets/design-export/json/FOVMf.json b/assets/design-export/json/FOVMf.json index ea41d3ea1..956a80b0d 100644 --- a/assets/design-export/json/FOVMf.json +++ b/assets/design-export/json/FOVMf.json @@ -85,7 +85,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, diff --git a/assets/design-export/json/FyV6E.json b/assets/design-export/json/FyV6E.json index da4ac2e36..27339ee3b 100644 --- a/assets/design-export/json/FyV6E.json +++ b/assets/design-export/json/FyV6E.json @@ -5,7 +5,6 @@ "y": 4461, "name": "Gameplane/Filter Popover", "context": "Servers Filter: Location and Namespace are draft values. Apply commits; Clear resets drafts; dismissal discards changes.", - "theme": {}, "reusable": true, "width": 280, "fill": "$surface/surface", @@ -40,7 +39,7 @@ "id": "XUciA", "name": "Location label", "fill": "$muted", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -53,7 +52,7 @@ "width": "fill_container", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } diff --git a/assets/design-export/json/GR1G9.json b/assets/design-export/json/GR1G9.json index 816ef3385..bbc45afff 100644 --- a/assets/design-export/json/GR1G9.json +++ b/assets/design-export/json/GR1G9.json @@ -75,7 +75,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -107,7 +107,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -149,7 +149,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, diff --git a/assets/design-export/json/HVhmb.json b/assets/design-export/json/HVhmb.json index bb92aab0b..ba17279fc 100644 --- a/assets/design-export/json/HVhmb.json +++ b/assets/design-export/json/HVhmb.json @@ -3,8 +3,8 @@ "id": "HVhmb", "x": 8200, "y": 63932, - "name": "Screen/Standalone — Remove cluster", - "context": "Removal errors stay inside the confirmation and reset when dismissed or reopened. Successful removal clears a matching current selection to empty without selecting local or another remote.", + "name": "Legacy/Clusters list — Remove registration — Moved", + "context": "Superseded by Cluster Details Overview, Nodes and Configuration. Backup destinations and registration removal are scoped to cluster Configuration.", "theme": { "semantic": "dark" }, @@ -85,7 +85,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Administration of registered clusters, connectivity and node inventory." }, "TgdLz": { "type": "frame", diff --git a/assets/design-export/json/I38x1.json b/assets/design-export/json/I38x1.json new file mode 100644 index 000000000..42684c71b --- /dev/null +++ b/assets/design-export/json/I38x1.json @@ -0,0 +1,837 @@ +{ + "type": "frame", + "id": "I38x1", + "x": 3049, + "y": 949, + "name": "Gameplane/Node Inventory Table", + "reusable": true, + "clip": true, + "width": 1132, + "fill": "$surface/secondary", + "cornerRadius": 20, + "layout": "vertical", + "padding": [ + 0, + 4, + 4, + 4 + ], + "children": [ + { + "type": "frame", + "id": "u0ZgPI", + "name": "Sortable column headers", + "width": "fill_container", + "height": 44, + "alignItems": "center", + "children": [ + { + "id": "R8zOyV", + "type": "ref", + "ref": "DYYKO", + "name": "Node ↑", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Node ↑" + } + } + }, + { + "id": "MEgE7", + "type": "ref", + "ref": "DYYKO", + "name": "Cluster ↕", + "width": 160, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Cluster ↕" + } + } + }, + { + "id": "lh3Jh", + "type": "ref", + "ref": "DYYKO", + "name": "Status ↕", + "width": 110, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Status ↕" + } + } + }, + { + "id": "b65Z84", + "type": "ref", + "ref": "DYYKO", + "name": "CPU ↕", + "width": 130, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "CPU ↕" + } + } + }, + { + "id": "JT6pI", + "type": "ref", + "ref": "DYYKO", + "name": "Memory ↕", + "width": 150, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Memory ↕" + } + } + }, + { + "id": "bDTM0", + "type": "ref", + "ref": "DYYKO", + "name": "Pods ↕", + "width": 110, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Pods ↕" + } + } + }, + { + "id": "v81JU", + "type": "ref", + "ref": "DYYKO", + "name": "Uptime ↕", + "width": 95, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Uptime ↕" + } + } + } + ] + }, + { + "type": "frame", + "id": "u91Ykc", + "name": "Node row kubelab-control", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "WIEOH", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ kubelab-control", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "kubelab-control", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "eKC7K", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Local", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Local", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "wzVRy", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ Ready", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Ready", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "YlI00", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ 24% · 1.9 / 8", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "24% · 1.9 / 8", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "TxOIO", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ 42% · 6.7 / 16 GiB", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "42% · 6.7 / 16 GiB", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "T1qux", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ 18 / 110", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "18 / 110", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "m6IC5", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ 12d 4h", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "12d 4h", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + }, + { + "type": "frame", + "id": "EmG5k", + "name": "Node row kubelab-worker-1", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "IBkte", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ kubelab-worker-1", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "kubelab-worker-1", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "Pg190", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Local", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Local", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "WNTFz", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ Ready", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Ready", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "iS3O8", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ 56% · 4.5 / 8", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "56% · 4.5 / 8", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "ngIDi", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ 65% · 10.4 / 16 GiB", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "65% · 10.4 / 16 GiB", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "Mz5Rh", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ 24 / 110", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "24 / 110", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "nv9Ri", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ 12d 4h", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "12d 4h", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + }, + { + "type": "frame", + "id": "p2FxaN", + "name": "Node row kubelab-worker-2", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "F5r32J", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ kubelab-worker-2", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "kubelab-worker-2", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "P4LYb", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Local", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Local", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "H9OueY", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ NotReady", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "NotReady", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "v4TV8j", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ Unknown", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Unknown", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "G8B8YF", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ Unknown", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Unknown", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "E7e2f", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ Unknown", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Unknown", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "Ur3ZP", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ Unknown", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Unknown", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + }, + { + "type": "frame", + "id": "LUWVT", + "name": "Node row remote-worker-1", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "oKp79", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ remote-worker-1", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "remote-worker-1", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "qm8e8", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Remote lab", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Remote lab", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "fT3QE", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ Ready", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Ready", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "VGdRN", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ 31% · 2.5 / 8", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "31% · 2.5 / 8", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "qqcxe", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ 28% · 4.5 / 16 GiB", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "28% · 4.5 / 16 GiB", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "A58ok", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ 14 / 110", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "14 / 110", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "nRX4f", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ 5d 2h", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "5d 2h", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + }, + { + "type": "frame", + "id": "g269sj", + "name": "Node row remote-worker-2", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "U5fdeZ", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ remote-worker-2", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "remote-worker-2", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "nweDg", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Remote lab", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Remote lab", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "dp3cy", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ Ready", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Ready", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "PMlmq", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ 18% · 1.4 / 8", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "18% · 1.4 / 8", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "WRazE", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ 36% · 5.8 / 16 GiB", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "36% · 5.8 / 16 GiB", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "mXEif", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ 12 / 110", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "12 / 110", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "y7535Y", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ 5d 2h", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "5d 2h", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + } + ] +} diff --git a/assets/design-export/json/IBUqJ.json b/assets/design-export/json/IBUqJ.json new file mode 100644 index 000000000..154e824df --- /dev/null +++ b/assets/design-export/json/IBUqJ.json @@ -0,0 +1,360 @@ +{ + "type": "frame", + "id": "IBUqJ", + "x": 19680, + "y": 63932, + "name": "Screen/Nodes — Add node target", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "DJiPT", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "VqyiS": { + "fill": "$accent/soft" + }, + "VqyiS/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "VqyiS/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "srews", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "JK792", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Nodes" + } + } + }, + { + "type": "frame", + "id": "SrXXC", + "name": "Body", + "opacity": 0.24, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "yW6ns", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Nodes" + }, + "VbHwU": { + "content": "Health and capacity across your authorized clusters." + }, + "TgdLz": { + "id": "U4n9S", + "type": "ref", + "ref": "cb4rt", + "name": "Add node", + "descendants": { + "U6q13B": { + "content": "Add node" + } + } + } + } + }, + { + "type": "frame", + "id": "VrP1N", + "name": "Node inventory totals", + "width": "fill_container", + "gap": 12, + "children": [ + { + "id": "tquyo", + "type": "ref", + "ref": "ZWcwn", + "name": "NODES READY", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "NODES READY" + }, + "QdcEm": { + "icon": "server" + }, + "vj2Ru": { + "content": "4 / 5" + }, + "h0n7zo": { + "content": "returned nodes" + } + } + }, + { + "id": "dapf3", + "type": "ref", + "ref": "ZWcwn", + "name": "CLUSTERS REPORTING", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "CLUSTERS REPORTING" + }, + "QdcEm": { + "icon": "network" + }, + "vj2Ru": { + "content": "2 / 3" + }, + "h0n7zo": { + "content": "one cluster unavailable" + } + } + }, + { + "id": "xjon9", + "type": "ref", + "ref": "ZWcwn", + "name": "CPU USAGE", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "CPU USAGE" + }, + "QdcEm": { + "icon": "cpu" + }, + "vj2Ru": { + "content": "32%" + }, + "h0n7zo": { + "content": "4 nodes reporting" + } + } + }, + { + "id": "jRaMd", + "type": "ref", + "ref": "ZWcwn", + "name": "MEMORY USAGE", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "MEMORY USAGE" + }, + "QdcEm": { + "icon": "memory-stick" + }, + "vj2Ru": { + "content": "43%" + }, + "h0n7zo": { + "content": "4 nodes reporting" + } + } + } + ] + }, + { + "id": "ptSqT", + "type": "ref", + "ref": "b2JF52", + "name": "Search and Filters", + "width": "fill_container" + }, + { + "type": "frame", + "id": "m3CJ2", + "name": "Partial inventory notice", + "width": "fill_container", + "fill": "$warning/soft", + "cornerRadius": 12, + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "LtwZm", + "name": "Coverage warning", + "fill": "$warning/soft-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Partial inventory · West is unavailable. Showing 5 returned nodes from 2 clusters.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "G1Vny", + "type": "ref", + "ref": "I38x1", + "name": "All authorized nodes", + "width": "fill_container" + }, + { + "type": "text", + "id": "DdpCr", + "name": "Inventory count", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5 nodes shown · Select a cluster name to open its details.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "mQHbL", + "layoutPosition": "absolute", + "x": 480, + "y": 220, + "name": "Add node target dialog", + "width": 600, + "fill": "$surface/surface", + "cornerRadius": 24, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 20, + "padding": 28, + "children": [ + { + "type": "text", + "id": "mGk1c", + "name": "Add node title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Add node", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 22, + "fontWeight": "600" + }, + { + "type": "text", + "id": "F4bIn", + "name": "Add node instructions", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Choose the cluster this machine should join.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "s3Xk1E", + "type": "ref", + "ref": "AT7ya", + "name": "Target cluster selection", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "Select a cluster…" + } + } + }, + { + "type": "text", + "id": "rRscs", + "name": "Cluster enrollment capability", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Local enrollment is available when cluster operations are enabled. Remote clusters provide their own enrollment instructions.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nFrXM", + "name": "Enrollment dialog actions", + "width": "fill_container", + "gap": 12, + "justifyContent": "end", + "children": [ + { + "id": "HSLjL", + "type": "ref", + "ref": "i6gfu", + "name": "Cancel", + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Cancel" + } + } + }, + { + "id": "JsWf8", + "type": "ref", + "ref": "cb4rt", + "name": "Continue after target selection", + "opacity": 0.45, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Continue" + } + } + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/J8GzM9.json b/assets/design-export/json/J8GzM9.json new file mode 100644 index 000000000..8e40f6657 --- /dev/null +++ b/assets/design-export/json/J8GzM9.json @@ -0,0 +1,213 @@ +{ + "type": "frame", + "id": "J8GzM9", + "x": 14760, + "y": 63932, + "name": "Screen/Cluster Details — Nodes", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "Q0pqx", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "S1Pit", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "v7q5NI", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Local › Nodes" + } + } + }, + { + "type": "frame", + "id": "bAyuY", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "k1ONI", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Local" + }, + "VbHwU": { + "content": "local · Built-in cluster" + }, + "TgdLz": { + "id": "ZrORy", + "type": "ref", + "ref": "cb4rt", + "name": "Add node", + "descendants": { + "U6q13B": { + "content": "Add node" + } + } + } + } + }, + { + "type": "frame", + "id": "enFs0", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "KFwE8", + "type": "ref", + "ref": "qAFbJ", + "name": "Overview tab", + "descendants": { + "VcxMI": { + "content": "Overview" + } + } + }, + { + "id": "R9kSft", + "type": "ref", + "ref": "CEWo3", + "name": "Nodes tab", + "descendants": { + "YUR8N": { + "content": "Nodes" + } + } + }, + { + "id": "C4iex", + "type": "ref", + "ref": "qAFbJ", + "name": "Configuration tab", + "descendants": { + "VcxMI": { + "content": "Configuration" + } + } + } + ] + }, + { + "id": "FBDxe", + "type": "ref", + "ref": "b2JF52", + "name": "Search and Filters — fixed Local cluster", + "width": "fill_container", + "context": "Popover contains Status only. Cluster: Local is fixed and does not count as an active filter.", + "descendants": { + "LMf8L": { + "enabled": true + } + } + }, + { + "type": "text", + "id": "odI4L", + "name": "Cluster node total", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "2 / 3 nodes ready · 3 nodes shown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "C6ZG2", + "type": "ref", + "ref": "I38x1", + "name": "Shared inventory table scoped to Local", + "width": "fill_container", + "descendants": { + "LUWVT": { + "enabled": false, + "width": 1124 + }, + "g269sj": { + "enabled": false, + "width": 1124 + } + } + }, + { + "id": "gxSZu", + "type": "ref", + "ref": "i6gfu", + "name": "View all nodes", + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View all nodes" + } + } + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/Mp7Ep.json b/assets/design-export/json/Mp7Ep.json index 446ef262c..903085f4a 100644 --- a/assets/design-export/json/Mp7Ep.json +++ b/assets/design-export/json/Mp7Ep.json @@ -1 +1,333 @@ -{"type":"frame","id":"Mp7Ep","x":2720,"y":1198,"name":"Gameplane/Admin Settings Nav","reusable":true,"width":220,"layout":"vertical","gap":2,"padding":[8,0],"children":[{"type":"frame","id":"eIjoV","name":"snGeneral","width":"fill_container","height":36,"fill":"$surface/secondary","cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"XuGUZ","width":16,"height":16,"icon":"cog","library":"lucide","weight":400,"fill":"$foreground/foreground"},{"type":"text","id":"DpMnt","fill":"$foreground/foreground","content":"General","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"vbQ98","name":"snTheme","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"dY0be","width":16,"height":16,"icon":"palette","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"qeqG6","fill":"$muted","content":"Theme","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"XEjdq","name":"snAuth","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"i7B23P","width":16,"height":16,"icon":"shield-check","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"BW5lA","fill":"$muted","content":"Authentication","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"fOAMd","name":"snBackups","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"TWq2N","width":16,"height":16,"icon":"archive","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"E3ECJ","fill":"$muted","content":"Backup destinations","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"OUh8J","name":"snModules","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"yQ8n2","width":16,"height":16,"icon":"boxes","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"BbpO8","fill":"$muted","content":"Module sources","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"L5pqnP","name":"snModRegistries","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"f9wpu","width":16,"height":16,"icon":"key","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"ue8wF","fill":"$muted","content":"Mod registries","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"KSTX4","name":"snNotifications","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"EdwLK","width":16,"height":16,"icon":"bell","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"IT8Ch","fill":"$muted","content":"Notifications","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"ZPLSA","name":"snTelemetry","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"w6pwjZ","width":16,"height":16,"icon":"activity","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"gAe7m","fill":"$muted","content":"Telemetry","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"bwo3a","name":"snUpdates","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"KzGJD","width":16,"height":16,"icon":"refresh-ccw","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"Y1aPw","fill":"$muted","content":"Updates","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]},{"type":"frame","id":"WlxKs","name":"snAbout","width":"fill_container","height":36,"cornerRadius":6,"gap":8,"padding":[8,12],"alignItems":"center","children":[{"type":"icon","id":"HsxmI","width":16,"height":16,"icon":"info","library":"lucide","weight":400,"fill":"$muted"},{"type":"text","id":"jY7mM","fill":"$muted","content":"About","fontFamily":"$typography/font-sans","fontSize":14,"fontWeight":"normal"}]}]} +{ + "type": "frame", + "id": "Mp7Ep", + "x": 2720, + "y": 1198, + "name": "Gameplane/Admin Settings Nav", + "reusable": true, + "width": 220, + "layout": "vertical", + "gap": 2, + "padding": [ + 8, + 0 + ], + "children": [ + { + "type": "frame", + "id": "eIjoV", + "name": "snGeneral", + "width": "fill_container", + "height": 36, + "fill": "$surface/secondary", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "XuGUZ", + "width": 16, + "height": 16, + "icon": "cog", + "library": "lucide", + "weight": 400, + "fill": "$foreground/foreground" + }, + { + "type": "text", + "id": "DpMnt", + "fill": "$foreground/foreground", + "content": "General", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "vbQ98", + "name": "snTheme", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "dY0be", + "width": 16, + "height": 16, + "icon": "palette", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "qeqG6", + "fill": "$muted", + "content": "Theme", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "XEjdq", + "name": "snAuth", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "i7B23P", + "width": 16, + "height": 16, + "icon": "shield-check", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "BW5lA", + "fill": "$muted", + "content": "Authentication", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OUh8J", + "name": "snModules", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "yQ8n2", + "width": 16, + "height": 16, + "icon": "boxes", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "BbpO8", + "fill": "$muted", + "content": "Module sources", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "L5pqnP", + "name": "snModRegistries", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "f9wpu", + "width": 16, + "height": 16, + "icon": "key", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "ue8wF", + "fill": "$muted", + "content": "Mod registries", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "KSTX4", + "name": "snNotifications", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "EdwLK", + "width": 16, + "height": 16, + "icon": "bell", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "IT8Ch", + "fill": "$muted", + "content": "Notifications", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZPLSA", + "name": "snTelemetry", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "w6pwjZ", + "width": 16, + "height": 16, + "icon": "activity", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "gAe7m", + "fill": "$muted", + "content": "Telemetry", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bwo3a", + "name": "snUpdates", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "KzGJD", + "width": 16, + "height": 16, + "icon": "refresh-ccw", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "Y1aPw", + "fill": "$muted", + "content": "Updates", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "WlxKs", + "name": "snAbout", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "HsxmI", + "width": 16, + "height": 16, + "icon": "info", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "jY7mM", + "fill": "$muted", + "content": "About", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] +} diff --git a/assets/design-export/json/N2nTf.json b/assets/design-export/json/N2nTf.json new file mode 100644 index 000000000..6a2b55e70 --- /dev/null +++ b/assets/design-export/json/N2nTf.json @@ -0,0 +1,1276 @@ +{ + "type": "frame", + "id": "N2nTf", + "x": 21280, + "y": 63932, + "name": "Screen/Mobile — Nodes — All clusters", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 390, + "height": 1620, + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "cSkTa", + "type": "ref", + "ref": "fGbVF", + "name": "Mobile navigation", + "width": "fill_container", + "descendants": { + "WOloN": { + "content": "Nodes" + } + } + }, + { + "type": "frame", + "id": "v7IOcE", + "name": "Mobile node inventory", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": 16, + "children": [ + { + "type": "text", + "id": "xspYX", + "name": "Page description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Health and capacity across clusters.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "anmfc", + "name": "Summary and add action", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AyJLH", + "name": "Ready nodes summary", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "4 / 5 ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 22, + "fontWeight": "600" + }, + { + "id": "UfqPv", + "type": "ref", + "ref": "cb4rt", + "name": "Add node", + "descendants": { + "U6q13B": { + "content": "Add node" + } + } + } + ] + }, + { + "id": "n6oP59", + "type": "ref", + "ref": "b2JF52", + "name": "Search and Filters — mobile", + "width": "fill_container", + "descendants": { + "lFVUp": { + "width": "fill_container" + }, + "SJPou": { + "enabled": false + } + } + }, + { + "id": "lPwhX", + "type": "ref", + "ref": "AT7ya", + "name": "Sort nodes", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "Sort: Node name ↑" + } + } + }, + { + "type": "frame", + "id": "lwDAR", + "name": "Partial inventory", + "width": "fill_container", + "fill": "$warning/soft", + "cornerRadius": 12, + "padding": 12, + "children": [ + { + "type": "text", + "id": "QHyKa", + "name": "Partial data message", + "fill": "$warning/soft-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "West is unavailable. Showing returned nodes from 2 clusters.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "N9jF2", + "name": "Stacked node kubelab-control", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "oVcLv", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "kubelab-control", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "b0hCk", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "e9smH", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Local", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "skDFf", + "name": "Node readiness", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e7BBT", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "IYQrV", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "SOSUF", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "MiM3F", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "24%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EH7Um", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "BYYKp", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "lSyUT", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "42%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "irSmz", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "injmY", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "ISCDR", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "q8IgD", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "18 / 110", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "BYITX", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "gxE2r", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "zYFNR", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "12d 4h", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ykmyY", + "name": "Stacked node kubelab-worker-1", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "numlc", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "kubelab-worker-1", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "VSged", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "w8Uf9", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Local", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "GlodJ", + "name": "Node readiness", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "O5J8X", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "P8HVB", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "RVbji", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wBN3n", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "56%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EBF5E", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "P0Nqw", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "acJIC", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "65%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "V67Jca", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "nQBSO", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "snNcS", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "H5I2T", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "24 / 110", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "J73VxW", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "vdw5N", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "mxgSz", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "12d 4h", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "z6YxP", + "name": "Stacked node kubelab-worker-2", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "FvI2s", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "kubelab-worker-2", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "koDwG", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "F2mEZ", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Local", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "S6orlq", + "name": "Node readiness", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "NotReady", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qBVBr", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "U0sXp", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "mAnns", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "NiLG7", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Unknown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DW6j5", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Y4TryU", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Ui13m", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Unknown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "s66D9", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "hn73J", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "MLpBD", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "fZvBi", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Unknown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DDdw0", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "vRNYR", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "l1vfix", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Unknown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "KWWKP", + "name": "Stacked node remote-worker-1", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "NYAez", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "remote-worker-1", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Aa2C9", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "cFh1D", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remote lab", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "FBFcr", + "name": "Node readiness", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "i6nxm", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "h1yKR", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "iqdyr", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "EesMg", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "31%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eu2UG", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Sbijb", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IYKGU", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "28%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Xx7rS", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "mA2kI", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Q4gCk", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "QMjup", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "14 / 110", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "RkV4t", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "IKtq2", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "qftfY", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5d 2h", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Y0SX7d", + "name": "Stacked node remote-worker-2", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "aIPdo", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "remote-worker-2", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "obZZp", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "hkNnH", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remote lab", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WRovs", + "name": "Node readiness", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "G5RNfq", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "foWuz", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "h8MKI", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "uUX8i", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "18%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IPycz", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "mC9rY", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Devwj", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "36%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qMNI5", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "cgcAq", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "WUg41", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KIytM", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "12 / 110", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TcSis", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "GP8oU", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "t0rMRW", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5d 2h", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "uQw9j", + "name": "Results summary", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5 nodes shown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] +} diff --git a/assets/design-export/json/NKiqf.json b/assets/design-export/json/NKiqf.json index 40a57a1e7..784648dca 100644 --- a/assets/design-export/json/NKiqf.json +++ b/assets/design-export/json/NKiqf.json @@ -51,7 +51,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Snapshots, schedules, and restores across your authorized locations.", + "content": "Snapshots, schedules, and restores across your authorized clusters.", "lineHeight": 1.4, "fontFamily": "$typography/font-sans", "fontSize": 14, diff --git a/assets/design-export/json/NLu2I.json b/assets/design-export/json/NLu2I.json index 0e8b087e8..76c49d35b 100644 --- a/assets/design-export/json/NLu2I.json +++ b/assets/design-export/json/NLu2I.json @@ -133,7 +133,7 @@ "id": "mtHaM", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -158,7 +158,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" diff --git a/assets/design-export/json/OecJ5.json b/assets/design-export/json/OecJ5.json new file mode 100644 index 000000000..291e7dcdb --- /dev/null +++ b/assets/design-export/json/OecJ5.json @@ -0,0 +1,416 @@ +{ + "type": "frame", + "id": "OecJ5", + "x": 16400, + "y": 63932, + "name": "Screen/Cluster Details — Configuration — Local", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1060, + "fill": "$background/background", + "children": [ + { + "id": "d4Ky9", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "Ydtdl", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "aBWaW", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Local › Configuration" + } + } + }, + { + "type": "frame", + "id": "OP0f5", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "oBQbJ", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Local" + }, + "VbHwU": { + "content": "local · Built-in cluster" + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "sR7uz", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "U25Za", + "type": "ref", + "ref": "qAFbJ", + "name": "Overview tab", + "descendants": { + "VcxMI": { + "content": "Overview" + } + } + }, + { + "id": "o9q64K", + "type": "ref", + "ref": "qAFbJ", + "name": "Nodes tab", + "descendants": { + "VcxMI": { + "content": "Nodes" + } + } + }, + { + "id": "gjz7s", + "type": "ref", + "ref": "CEWo3", + "name": "Configuration tab", + "descendants": { + "YUR8N": { + "content": "Configuration" + } + } + } + ] + }, + { + "type": "frame", + "id": "VzNfk", + "name": "Access", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "p5wOE", + "name": "Access heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Access", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "kOhUH", + "name": "Access description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Download a kubeconfig for this cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "a3VJJN", + "type": "ref", + "ref": "i6gfu", + "name": "Download kubeconfig", + "descendants": { + "FCYQ9": { + "icon": "download" + }, + "GqVun": { + "content": "Download kubeconfig" + } + } + }, + { + "type": "text", + "id": "MU5Tb", + "name": "Enrollment link", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "To join a machine, use Add node on the Nodes tab.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LgatB", + "name": "Storage", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "A9tT3", + "name": "Storage heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Storage", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "zYT9I", + "name": "Storage description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Set at install time. These values are read-only.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "iXn3F", + "name": "Storage class row", + "width": "fill_container", + "gap": 24, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "B6kNt", + "name": "Storage class label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Game data storage class", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Q3dDF", + "name": "Storage class value", + "fill": "$foreground/foreground", + "content": "fast-nvme", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "WegCt", + "name": "Backup destinations", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "ySXQv", + "name": "Backup destinations heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Backup destinations", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "cijZ0", + "name": "Backup destinations description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Repositories used by servers running on this cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "XPTLB", + "name": "Backup destination row", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "C0j1T", + "name": "Repository details", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "hnKtC", + "name": "Repository name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "nightly-backups", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "CoDeX", + "name": "Repository URL", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "s3:s3.example.com/gameplane", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "QHmLE", + "type": "ref", + "ref": "i6gfu", + "name": "Delete destination", + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Delete" + } + } + } + ] + }, + { + "id": "b4j6s", + "type": "ref", + "ref": "cb4rt", + "name": "Add backup destination", + "descendants": { + "U6q13B": { + "content": "Add destination" + } + } + } + ] + }, + { + "type": "text", + "id": "M0m68N", + "name": "Built-in cluster removal", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The built-in local cluster cannot be removed.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/QQtUD.json b/assets/design-export/json/QQtUD.json index dd91d5818..da0a54ac9 100644 --- a/assets/design-export/json/QQtUD.json +++ b/assets/design-export/json/QQtUD.json @@ -133,7 +133,7 @@ "id": "VXTz6", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -158,7 +158,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" diff --git a/assets/design-export/json/RC3Kf.json b/assets/design-export/json/RC3Kf.json index 968866764..6bef4aedb 100644 --- a/assets/design-export/json/RC3Kf.json +++ b/assets/design-export/json/RC3Kf.json @@ -3,7 +3,8 @@ "id": "RC3Kf", "x": 0, "y": 82231, - "name": "Screen/Admin Settings — Backup destinations", + "name": "Legacy/Admin Settings — Backup destinations — Moved", + "context": "Superseded by Cluster Details Overview, Nodes and Configuration. Backup destinations and registration removal are scoped to cluster Configuration.", "theme": { "semantic": "dark" }, @@ -108,15 +109,6 @@ }, "DpMnt": { "fill": "$muted" - }, - "fOAMd": { - "fill": "$surface/secondary" - }, - "TWq2N": { - "fill": "$foreground/foreground" - }, - "E3ECJ": { - "fill": "$foreground/foreground" } } }, diff --git a/assets/design-export/json/RpHBg.json b/assets/design-export/json/RpHBg.json index 7c9fca11b..a813c8872 100644 --- a/assets/design-export/json/RpHBg.json +++ b/assets/design-export/json/RpHBg.json @@ -85,7 +85,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Administration of registered clusters, connectivity and node inventory." }, "TgdLz": { "type": "frame", @@ -183,4 +183,3 @@ } ] } - diff --git a/assets/design-export/json/T70dJ.json b/assets/design-export/json/T70dJ.json new file mode 100644 index 000000000..775e0f6b0 --- /dev/null +++ b/assets/design-export/json/T70dJ.json @@ -0,0 +1,416 @@ +{ + "type": "frame", + "id": "T70dJ", + "x": 21830, + "y": 63932, + "name": "Screen/Cluster Details — Configuration — Default storage", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1060, + "fill": "$background/background", + "children": [ + { + "id": "K5BoBu", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "baGuL", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "tKTDq", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Local › Configuration" + } + } + }, + { + "type": "frame", + "id": "ghWVM", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "Qy7tO", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Local" + }, + "VbHwU": { + "content": "local · Built-in cluster" + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "e7QMO9", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "kWnAH", + "type": "ref", + "ref": "qAFbJ", + "name": "Overview tab", + "descendants": { + "VcxMI": { + "content": "Overview" + } + } + }, + { + "id": "RqiZh", + "type": "ref", + "ref": "qAFbJ", + "name": "Nodes tab", + "descendants": { + "VcxMI": { + "content": "Nodes" + } + } + }, + { + "id": "yzR6r", + "type": "ref", + "ref": "CEWo3", + "name": "Configuration tab", + "descendants": { + "YUR8N": { + "content": "Configuration" + } + } + } + ] + }, + { + "type": "frame", + "id": "ikyuE", + "name": "Access", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "tuItf", + "name": "Access heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Access", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "CibbM", + "name": "Access description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Download a kubeconfig for this cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "eSxBK", + "type": "ref", + "ref": "i6gfu", + "name": "Download kubeconfig", + "descendants": { + "FCYQ9": { + "icon": "download" + }, + "GqVun": { + "content": "Download kubeconfig" + } + } + }, + { + "type": "text", + "id": "qFY0u", + "name": "Enrollment link", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "To join a machine, use Add node on the Nodes tab.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "slD0X", + "name": "Storage", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "KeNCX", + "name": "Storage heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Storage", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ia7Xn", + "name": "Storage description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No storage class is set. New volumes use the cluster default. This setting is read-only.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "voacz", + "name": "Storage class row", + "width": "fill_container", + "gap": 24, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "s5hyZ", + "name": "Storage class label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Game data storage class", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "xm6O0", + "name": "Storage class value", + "fill": "$foreground/foreground", + "content": "Cluster default", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "f00de", + "name": "Backup destinations", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "qXkuX", + "name": "Backup destinations heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Backup destinations", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "L6ioUD", + "name": "Backup destinations description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Repositories used by servers running on this cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "A6iyO", + "name": "Backup destination row", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "FD8Z3", + "name": "Repository details", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "xGtq2", + "name": "Repository name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "nightly-backups", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "mmOOn", + "name": "Repository URL", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "s3:s3.example.com/gameplane", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "NWW4J", + "type": "ref", + "ref": "i6gfu", + "name": "Delete destination", + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Delete" + } + } + } + ] + }, + { + "id": "MDGwG", + "type": "ref", + "ref": "cb4rt", + "name": "Add backup destination", + "descendants": { + "U6q13B": { + "content": "Add destination" + } + } + } + ] + }, + { + "type": "text", + "id": "J0MVH", + "name": "Built-in cluster removal", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The built-in local cluster cannot be removed.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/UCNBm.json b/assets/design-export/json/UCNBm.json index 6e40c8c31..f025d1e14 100644 --- a/assets/design-export/json/UCNBm.json +++ b/assets/design-export/json/UCNBm.json @@ -3,7 +3,7 @@ "id": "UCNBm", "x": 3280, "y": 14602, - "name": "Screen/Servers — Location filters open", + "name": "Screen/Servers — Cluster filters open", "context": "Implemented at 8e08a60f. Filter fields are drafts; Apply commits, Clear resets drafts, dismissal discards edits. Server choices retain location and namespace.", "theme": { "semantic": "dark" @@ -115,7 +115,7 @@ "content": "Servers" }, "VbHwU": { - "content": "Manage your game servers across all authorized locations." + "content": "Manage your game servers across all authorized clusters." }, "TgdLz": { "children": [ diff --git a/assets/design-export/json/UMJli.json b/assets/design-export/json/UMJli.json index b24abf37f..a7091ca9d 100644 --- a/assets/design-export/json/UMJli.json +++ b/assets/design-export/json/UMJli.json @@ -134,7 +134,7 @@ "id": "PlMgh", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -159,7 +159,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" diff --git a/assets/design-export/json/Uzzje.json b/assets/design-export/json/Uzzje.json index ffe533d20..81c448108 100644 --- a/assets/design-export/json/Uzzje.json +++ b/assets/design-export/json/Uzzje.json @@ -85,7 +85,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, diff --git a/assets/design-export/json/W8idqY.json b/assets/design-export/json/W8idqY.json index 367cf496d..d241a74ad 100644 --- a/assets/design-export/json/W8idqY.json +++ b/assets/design-export/json/W8idqY.json @@ -134,7 +134,7 @@ "id": "U9yGXG", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -159,7 +159,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" diff --git a/assets/design-export/json/Wj0V4.json b/assets/design-export/json/Wj0V4.json index 279e10b0d..66e761db0 100644 --- a/assets/design-export/json/Wj0V4.json +++ b/assets/design-export/json/Wj0V4.json @@ -409,7 +409,7 @@ "id": "jrzYL", "name": "Central management explanation", "fill": "$muted", - "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.4285714286, "fontFamily": "$typography/font-sans", "fontSize": 14, diff --git a/assets/design-export/json/WrmU5.json b/assets/design-export/json/WrmU5.json index 8aeeccdd4..2918691a5 100644 --- a/assets/design-export/json/WrmU5.json +++ b/assets/design-export/json/WrmU5.json @@ -85,7 +85,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Registered clusters, connectivity and node inventory." }, "TgdLz": { "type": "frame", @@ -236,14 +236,14 @@ "id": "i0uSmF", "type": "ref", "ref": "cb4rt", - "name": "View servers", + "name": "Open cluster", "opacity": 1, "descendants": { "Xpn7K": { "enabled": false }, "U6q13B": { - "content": "View servers" + "content": "Open cluster" } } }, @@ -261,21 +261,6 @@ "content": "View nodes" } } - }, - { - "id": "W1073i", - "type": "ref", - "ref": "i6gfu", - "name": "Remove", - "opacity": 1, - "descendants": { - "FCYQ9": { - "enabled": false - }, - "GqVun": { - "content": "Remove" - } - } } ] } @@ -373,14 +358,14 @@ "id": "U7jY7p", "type": "ref", "ref": "cb4rt", - "name": "View servers", + "name": "Open cluster", "opacity": 1, "descendants": { "Xpn7K": { "enabled": false }, "U6q13B": { - "content": "View servers" + "content": "Open cluster" } } }, @@ -398,21 +383,6 @@ "content": "View nodes" } } - }, - { - "id": "Bd9ic", - "type": "ref", - "ref": "i6gfu", - "name": "Remove", - "opacity": 1, - "descendants": { - "FCYQ9": { - "enabled": false - }, - "GqVun": { - "content": "Remove" - } - } } ] } @@ -426,4 +396,3 @@ } ] } - diff --git a/assets/design-export/json/Y96CaE.json b/assets/design-export/json/Y96CaE.json index 6e477fa6d..48fd5bfd7 100644 --- a/assets/design-export/json/Y96CaE.json +++ b/assets/design-export/json/Y96CaE.json @@ -4,6 +4,7 @@ "x": 3889, "y": 7617, "name": "Gameplane/Cluster Resources Card", + "context": "View nodes opens the all-cluster inventory at /nodes and preserves the Dashboard cluster filter.", "reusable": true, "width": "fill_container(546)", "fill": "$surface/surface", @@ -48,7 +49,7 @@ "id": "RD2NC", "name": "link", "fill": "$accent/accent", - "content": "View cluster", + "content": "View nodes", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" diff --git a/assets/design-export/json/YPKbV.json b/assets/design-export/json/YPKbV.json index b8a346de1..4ebc8b412 100644 --- a/assets/design-export/json/YPKbV.json +++ b/assets/design-export/json/YPKbV.json @@ -3,7 +3,7 @@ "id": "YPKbV", "x": 3280, "y": 12822, - "name": "Screen/Dashboard — Partial location coverage", + "name": "Screen/Dashboard — Partial cluster coverage", "context": "Counts cover returned resources only. Unknown inventory and player metrics use an em dash, never zero.", "theme": { "semantic": "dark" @@ -60,7 +60,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -92,7 +92,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -190,7 +190,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, diff --git a/assets/design-export/json/b2JF52.json b/assets/design-export/json/b2JF52.json new file mode 100644 index 000000000..17f971d9a --- /dev/null +++ b/assets/design-export/json/b2JF52.json @@ -0,0 +1,87 @@ +{ + "type": "frame", + "id": "b2JF52", + "x": -847, + "y": -516, + "name": "Gameplane/Nodes Search and Filters Row", + "context": "Search matches Backups plain HeroUI Input, maximum 320px desktop, flexible on narrow screens. Filters stays at the right. Optional fixed-cluster pill is read-only and never increases the badge count. Opens Gameplane/Nodes Filters Popover.", + "reusable": true, + "width": 1124, + "gap": 12, + "alignItems": "center", + "children": [ + { + "id": "lFVUp", + "type": "ref", + "ref": "D0cDM", + "name": "Search nodes", + "width": 320, + "descendants": { + "PJERm": { + "content": "Search nodes…" + } + } + }, + { + "type": "frame", + "id": "SJPou", + "name": "Flexible space", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "LMf8L", + "x": 952, + "y": 0, + "name": "Fixed cluster scope", + "enabled": false, + "width": 180, + "height": 36, + "fill": "$surface/secondary", + "cornerRadius": 12, + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "T9hiu", + "name": "Fixed cluster icon", + "width": 16, + "height": 16, + "icon": "network", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "y6hK9", + "name": "Current cluster", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Cluster: Local", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "xJuXI", + "type": "ref", + "ref": "xrNf3", + "name": "Filters trigger", + "descendants": { + "Wfxw3": { + "enabled": false + } + } + } + ] +} diff --git a/assets/design-export/json/dxdEi.json b/assets/design-export/json/dxdEi.json index d04bee123..93a42f347 100644 --- a/assets/design-export/json/dxdEi.json +++ b/assets/design-export/json/dxdEi.json @@ -3,7 +3,8 @@ "id": "dxdEi", "x": 1640, "y": 65232, - "name": "Screen/Cluster Settings — Default storage class", + "name": "Legacy/Cluster Settings — Default storage — Superseded", + "context": "Superseded by Cluster Details Configuration and Nodes screens. Preserved for historical install-time storage states.", "theme": { "semantic": "dark" }, diff --git a/assets/design-export/json/f1Vga.json b/assets/design-export/json/f1Vga.json index ec27b9107..dc82e6781 100644 --- a/assets/design-export/json/f1Vga.json +++ b/assets/design-export/json/f1Vga.json @@ -134,7 +134,7 @@ "id": "Y5o8fo", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -159,7 +159,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" diff --git a/assets/design-export/json/fK8Bi.json b/assets/design-export/json/fK8Bi.json index 932c98d4f..2c7f6c9ee 100644 --- a/assets/design-export/json/fK8Bi.json +++ b/assets/design-export/json/fK8Bi.json @@ -145,7 +145,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", diff --git a/assets/design-export/json/fbBqA.json b/assets/design-export/json/fbBqA.json index a10b7dfc0..c29fd23ee 100644 --- a/assets/design-export/json/fbBqA.json +++ b/assets/design-export/json/fbBqA.json @@ -146,7 +146,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", diff --git a/assets/design-export/json/iYUWc.json b/assets/design-export/json/iYUWc.json index 2e7a467fc..13ec22340 100644 --- a/assets/design-export/json/iYUWc.json +++ b/assets/design-export/json/iYUWc.json @@ -3,7 +3,7 @@ "id": "iYUWc", "x": 0, "y": 63932, - "name": "Screen/Clusters — Registered locations", + "name": "Screen/Clusters — Registered clusters", "theme": { "semantic": "dark" }, @@ -81,7 +81,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Registered clusters, connectivity and node inventory." }, "TgdLz": { "enabled": false @@ -180,13 +180,13 @@ "id": "X8IeOl", "type": "ref", "ref": "cb4rt", - "name": "View servers", + "name": "Open cluster", "descendants": { "Xpn7K": { "enabled": false }, "U6q13B": { - "content": "View servers" + "content": "Open cluster" } } }, @@ -272,13 +272,13 @@ "id": "YRYRA", "type": "ref", "ref": "cb4rt", - "name": "View servers", + "name": "Open cluster", "descendants": { "Xpn7K": { "enabled": false }, "U6q13B": { - "content": "View servers" + "content": "Open cluster" } } }, diff --git a/assets/design-export/json/j24cXg.json b/assets/design-export/json/j24cXg.json index 8b5e70ee3..7359da527 100644 --- a/assets/design-export/json/j24cXg.json +++ b/assets/design-export/json/j24cXg.json @@ -59,7 +59,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -91,7 +91,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -133,7 +133,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, diff --git a/assets/design-export/json/j9W8A.json b/assets/design-export/json/j9W8A.json index 64f8a8177..cd6951c3c 100644 --- a/assets/design-export/json/j9W8A.json +++ b/assets/design-export/json/j9W8A.json @@ -3,7 +3,8 @@ "id": "j9W8A", "x": 0, "y": 65232, - "name": "Screen/Cluster Settings", + "name": "Legacy/Cluster Settings — Superseded", + "context": "Superseded by Cluster Details Configuration and Nodes screens. Preserved for historical install-time storage states.", "theme": { "semantic": "dark" }, diff --git a/assets/design-export/json/kK8Ji.json b/assets/design-export/json/kK8Ji.json index 8e28f1ba9..2e9520868 100644 --- a/assets/design-export/json/kK8Ji.json +++ b/assets/design-export/json/kK8Ji.json @@ -410,7 +410,7 @@ "id": "vudRw", "name": "Central management explanation", "fill": "$muted", - "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.4285714286, "fontFamily": "$typography/font-sans", "fontSize": 14, diff --git a/assets/design-export/json/kKFX9.json b/assets/design-export/json/kKFX9.json index 6648c3142..48c413188 100644 --- a/assets/design-export/json/kKFX9.json +++ b/assets/design-export/json/kKFX9.json @@ -238,7 +238,7 @@ "id": "lZ6pP", "type": "ref", "ref": "W8XiN", - "name": "navCluster", + "name": "navClusters", "width": "fill_container", "cornerRadius": 6, "padding": [ @@ -258,6 +258,30 @@ } } }, + { + "id": "VqyiS", + "type": "ref", + "ref": "W8XiN", + "name": "navNodes", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 8, + 12 + ], + "gap": 12, + "height": 36, + "alignItems": "center", + "fill": "#00000000", + "descendants": { + "Sr2Q5": { + "icon": "server" + }, + "Ra9NK": { + "content": "Nodes" + } + } + }, { "id": "dNsbz", "type": "ref", diff --git a/assets/design-export/json/l64bd.json b/assets/design-export/json/l64bd.json index ce457722f..f7a3bd0de 100644 --- a/assets/design-export/json/l64bd.json +++ b/assets/design-export/json/l64bd.json @@ -85,7 +85,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, diff --git a/assets/design-export/json/n2qdi.json b/assets/design-export/json/n2qdi.json index 4de24ab11..d3758a806 100644 --- a/assets/design-export/json/n2qdi.json +++ b/assets/design-export/json/n2qdi.json @@ -85,7 +85,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Administration of registered clusters, connectivity and node inventory." }, "TgdLz": { "type": "frame", @@ -159,4 +159,3 @@ } ] } - diff --git a/assets/design-export/json/nNL3E.json b/assets/design-export/json/nNL3E.json index 4f202d6f4..9581d0120 100644 --- a/assets/design-export/json/nNL3E.json +++ b/assets/design-export/json/nNL3E.json @@ -134,7 +134,7 @@ "id": "qnMPd", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -159,7 +159,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" diff --git a/assets/design-export/json/oyoTs.json b/assets/design-export/json/oyoTs.json index d650bc766..76a64903b 100644 --- a/assets/design-export/json/oyoTs.json +++ b/assets/design-export/json/oyoTs.json @@ -59,7 +59,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -91,7 +91,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -133,7 +133,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, diff --git a/assets/design-export/json/pnAbF.json b/assets/design-export/json/pnAbF.json index 40d02247e..756a3e194 100644 --- a/assets/design-export/json/pnAbF.json +++ b/assets/design-export/json/pnAbF.json @@ -83,7 +83,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", diff --git a/assets/design-export/json/r139UP.json b/assets/design-export/json/r139UP.json index c1fc1296b..096d13f70 100644 --- a/assets/design-export/json/r139UP.json +++ b/assets/design-export/json/r139UP.json @@ -141,7 +141,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -231,4 +231,3 @@ } ] } - diff --git a/assets/design-export/json/rCQaM.json b/assets/design-export/json/rCQaM.json index b1f0cf411..d3512ce46 100644 --- a/assets/design-export/json/rCQaM.json +++ b/assets/design-export/json/rCQaM.json @@ -5,7 +5,6 @@ "y": 4461, "name": "Gameplane/Backups Filter Popover", "context": "Backups and Restores filter drafts: Location, Server, Phase. Schedules hides Phase. Apply commits; Clear resets drafts.", - "theme": {}, "reusable": true, "width": 280, "fill": "$surface/surface", @@ -29,7 +28,7 @@ "id": "WqfpR", "name": "Location label", "fill": "$muted", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -42,7 +41,7 @@ "width": "fill_container", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } diff --git a/assets/design-export/json/t56aLu.json b/assets/design-export/json/t56aLu.json new file mode 100644 index 000000000..1f9fa9d2c --- /dev/null +++ b/assets/design-export/json/t56aLu.json @@ -0,0 +1,350 @@ +{ + "type": "frame", + "id": "t56aLu", + "x": 18040, + "y": 63932, + "name": "Screen/Cluster Details — Configuration — Remote", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1060, + "fill": "$background/background", + "children": [ + { + "id": "qjaPk", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "oyYlV", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "Twrfz", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Remote lab › Configuration" + } + } + }, + { + "type": "frame", + "id": "iAnOJ", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "diWXH", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Remote lab" + }, + "VbHwU": { + "content": "remote-lab · Registered workload cluster" + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "kQWhP", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "Mc5Lj", + "type": "ref", + "ref": "qAFbJ", + "name": "Overview tab", + "descendants": { + "VcxMI": { + "content": "Overview" + } + } + }, + { + "id": "GHUO8", + "type": "ref", + "ref": "qAFbJ", + "name": "Nodes tab", + "descendants": { + "VcxMI": { + "content": "Nodes" + } + } + }, + { + "id": "XmsVL", + "type": "ref", + "ref": "CEWo3", + "name": "Configuration tab", + "descendants": { + "YUR8N": { + "content": "Configuration" + } + } + } + ] + }, + { + "type": "frame", + "id": "P0wXMc", + "name": "Access", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "E9Yw2", + "name": "Access heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Access", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "pgCgw", + "name": "Access description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Node enrollment and kubeconfig downloads are managed at this remote cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Q90Zz", + "name": "Storage", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "VctHF", + "name": "Storage heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Storage", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "kC6jR", + "name": "Storage description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Storage settings are managed on this cluster. Install-time storage values are available only for the built-in local cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dalvF", + "name": "Backup destinations", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "xR3KG", + "name": "Backup destinations heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Backup destinations", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "L42aR", + "name": "Backup destinations description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Repositories used by servers running on Remote lab.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "N5Xfuf", + "name": "Empty backup destinations", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No backup destinations configured. Add one to enable snapshots.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "hGg1x", + "type": "ref", + "ref": "cb4rt", + "name": "Add remote backup destination", + "descendants": { + "U6q13B": { + "content": "Add destination" + } + } + } + ] + }, + { + "type": "frame", + "id": "iue9v", + "name": "Remove cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$danger/soft", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "aUbTB", + "name": "Remove cluster heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remove cluster", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "M1ms36", + "name": "Remove cluster description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remove this panel’s connection to Remote lab. Running workloads remain on the cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "Bfl2v", + "type": "ref", + "ref": "f3m06I", + "name": "Remove cluster registration", + "descendants": { + "sCPUE": { + "icon": "trash-2" + }, + "RY93S": { + "content": "Remove cluster" + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/tTSdi.json b/assets/design-export/json/tTSdi.json index b8fdf45a9..c78394d7f 100644 --- a/assets/design-export/json/tTSdi.json +++ b/assets/design-export/json/tTSdi.json @@ -145,7 +145,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", diff --git a/assets/design-export/json/uMiwd.json b/assets/design-export/json/uMiwd.json index 4f4bef769..c5ef1dbfa 100644 --- a/assets/design-export/json/uMiwd.json +++ b/assets/design-export/json/uMiwd.json @@ -409,7 +409,7 @@ "id": "LLHgj", "name": "Central management explanation", "fill": "$muted", - "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.4285714286, "fontFamily": "$typography/font-sans", "fontSize": 14, diff --git a/assets/design-export/json/vUqMl.json b/assets/design-export/json/vUqMl.json index 91cb1c182..e11cd965f 100644 --- a/assets/design-export/json/vUqMl.json +++ b/assets/design-export/json/vUqMl.json @@ -133,7 +133,7 @@ "id": "NZNG4", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -158,7 +158,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" diff --git a/assets/design-export/json/w34N9.json b/assets/design-export/json/w34N9.json index b3e7b59a9..3ac3b2516 100644 --- a/assets/design-export/json/w34N9.json +++ b/assets/design-export/json/w34N9.json @@ -65,7 +65,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "enabled": false, @@ -100,7 +100,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } diff --git a/assets/design-export/json/wqnM9.json b/assets/design-export/json/wqnM9.json new file mode 100644 index 000000000..e07de9e99 --- /dev/null +++ b/assets/design-export/json/wqnM9.json @@ -0,0 +1,314 @@ +{ + "type": "frame", + "id": "wqnM9", + "x": 13120, + "y": 63932, + "name": "Screen/Cluster Details — Overview", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "HIs18", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "xGoph", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "n0G3M", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Remote lab" + } + } + }, + { + "type": "frame", + "id": "FDFsb", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "szMIY", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Remote lab" + }, + "VbHwU": { + "content": "remote-lab · Registered workload cluster" + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "A3iJ6", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "slJKl", + "type": "ref", + "ref": "CEWo3", + "name": "Overview tab", + "descendants": { + "YUR8N": { + "content": "Overview" + } + } + }, + { + "id": "Redwr", + "type": "ref", + "ref": "qAFbJ", + "name": "Nodes tab", + "descendants": { + "VcxMI": { + "content": "Nodes" + } + } + }, + { + "id": "nohtR", + "type": "ref", + "ref": "qAFbJ", + "name": "Configuration tab", + "descendants": { + "VcxMI": { + "content": "Configuration" + } + } + } + ] + }, + { + "type": "frame", + "id": "i7XRlr", + "name": "Cluster summary", + "width": "fill_container", + "gap": 16, + "children": [ + { + "id": "jNKF5", + "type": "ref", + "ref": "ZWcwn", + "name": "API CONNECTIVITY", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "API CONNECTIVITY" + }, + "QdcEm": { + "icon": "network" + }, + "vj2Ru": { + "content": "Healthy", + "fontSize": 22 + }, + "h0n7zo": { + "content": "Last checked just now" + } + } + }, + { + "id": "pAEH8", + "type": "ref", + "ref": "ZWcwn", + "name": "NODES READY", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "NODES READY" + }, + "QdcEm": { + "icon": "server" + }, + "vj2Ru": { + "content": "2 / 2", + "fontSize": 22 + }, + "h0n7zo": { + "content": "inventory available" + } + } + }, + { + "id": "MB4N2", + "type": "ref", + "ref": "ZWcwn", + "name": "KUBERNETES", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "KUBERNETES" + }, + "QdcEm": { + "icon": "boxes" + }, + "vj2Ru": { + "content": "v1.35.5", + "fontSize": 22 + }, + "h0n7zo": { + "content": "reported server version" + } + } + } + ] + }, + { + "type": "frame", + "id": "MLeU1", + "name": "Connection", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "KUqWG", + "name": "Connection heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Connection", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "diCa2", + "name": "Connection description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API status reports Kubernetes connectivity. Game server and gateway health are shown with each server.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vSQ4P", + "name": "Registration identity", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Cluster ID: remote-lab", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "F82Y7z", + "name": "Cluster destinations", + "width": "fill_container", + "gap": 12, + "children": [ + { + "id": "QQJuU", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "descendants": { + "Xpn7K": { + "icon": "server" + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "zwS4R", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "descendants": { + "FCYQ9": { + "icon": "layers" + }, + "GqVun": { + "content": "View nodes" + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/xjVA4.json b/assets/design-export/json/xjVA4.json new file mode 100644 index 000000000..1706f9144 --- /dev/null +++ b/assets/design-export/json/xjVA4.json @@ -0,0 +1,149 @@ +{ + "type": "frame", + "id": "xjVA4", + "x": -1207, + "y": -516, + "name": "Gameplane/Nodes Filters Popover", + "context": "Backups pattern: opening copies applied Cluster and Status into drafts. Apply commits and closes; Clear resets drafts only. Closing without Apply discards drafts. Search remains outside the popover and badge. Cluster options: All clusters and authorized clusters. Status options: All statuses, Ready, Not ready, Unknown. In fixed-cluster detail hide Cluster and count Status only.", + "reusable": true, + "width": 280, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "d7pVy", + "name": "Cluster field", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Q5X01", + "name": "Cluster label", + "fill": "$muted", + "content": "Cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "XQluj", + "type": "ref", + "ref": "AT7ya", + "name": "Cluster select", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "All clusters", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "m2s7S", + "name": "Status field", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "b2uTl4", + "name": "Status label", + "fill": "$muted", + "content": "Status", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "O1IZPo", + "type": "ref", + "ref": "AT7ya", + "name": "Status select", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "All statuses", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "EHlqt", + "name": "Clear and Apply", + "width": "fill_container", + "gap": 8, + "padding": [ + 6, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Zq2ru", + "name": "btnClear", + "height": 28, + "cornerRadius": 6, + "padding": [ + 0, + 8 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DEESq", + "fill": "$foreground/foreground", + "content": "Clear", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "rKBVi", + "name": "btnApply", + "height": 28, + "fill": "$accent/accent", + "cornerRadius": 6, + "padding": [ + 0, + 8 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qH69x", + "fill": "$accent/foreground", + "content": "Apply", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/xrNf3.json b/assets/design-export/json/xrNf3.json new file mode 100644 index 000000000..6d3fc77d4 --- /dev/null +++ b/assets/design-export/json/xrNf3.json @@ -0,0 +1,53 @@ +{ + "type": "frame", + "id": "xrNf3", + "x": -1400, + "y": -516, + "name": "Gameplane/Nodes Filters Button", + "context": "Same trigger style as Backups. Badge counts applied Cluster + Status filters, never search. Hide badge at zero. Fixed-cluster detail counts Status only.", + "reusable": true, + "height": 36, + "fill": "$default/default", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "YMMQj", + "name": "Filter icon", + "width": 16, + "height": 16, + "icon": "list-filter", + "library": "lucide", + "fill": "$foreground/foreground" + }, + { + "type": "text", + "id": "UTUE9", + "name": "Filter label", + "fill": "$foreground/foreground", + "content": "Filter", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "id": "Wfxw3", + "type": "ref", + "ref": "KmZnF", + "name": "Active filters count", + "descendants": { + "xZ9ah": { + "content": "2" + } + } + } + ] +} diff --git a/assets/design-export/json/zFiOW.json b/assets/design-export/json/zFiOW.json index 156df8280..f96ff56a4 100644 --- a/assets/design-export/json/zFiOW.json +++ b/assets/design-export/json/zFiOW.json @@ -82,7 +82,7 @@ "content": "Servers" }, "VbHwU": { - "content": "Manage your game servers across all authorized locations." + "content": "Manage your game servers across all authorized clusters." }, "TgdLz": { "type": "frame", diff --git a/assets/design-export/json/zj5a3.json b/assets/design-export/json/zj5a3.json index f06b57a53..b84c4a2cf 100644 --- a/assets/design-export/json/zj5a3.json +++ b/assets/design-export/json/zj5a3.json @@ -75,7 +75,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -107,7 +107,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -149,7 +149,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, diff --git a/assets/design-export/screenshots/BdmAW.png b/assets/design-export/screenshots/BdmAW.png new file mode 100644 index 000000000..888bd33e6 Binary files /dev/null and b/assets/design-export/screenshots/BdmAW.png differ diff --git a/assets/design-export/screenshots/Be7ZW.png b/assets/design-export/screenshots/Be7ZW.png index 09e382bdd..db3969aa8 100644 Binary files a/assets/design-export/screenshots/Be7ZW.png and b/assets/design-export/screenshots/Be7ZW.png differ diff --git a/assets/design-export/screenshots/Cb3eC.png b/assets/design-export/screenshots/Cb3eC.png index d7ad99f2d..52c1f26c4 100644 Binary files a/assets/design-export/screenshots/Cb3eC.png and b/assets/design-export/screenshots/Cb3eC.png differ diff --git a/assets/design-export/screenshots/D9QYZ.png b/assets/design-export/screenshots/D9QYZ.png index 2101599be..e2deaa245 100644 Binary files a/assets/design-export/screenshots/D9QYZ.png and b/assets/design-export/screenshots/D9QYZ.png differ diff --git a/assets/design-export/screenshots/DPrYX.png b/assets/design-export/screenshots/DPrYX.png index da48a330a..223509a89 100644 Binary files a/assets/design-export/screenshots/DPrYX.png and b/assets/design-export/screenshots/DPrYX.png differ diff --git a/assets/design-export/screenshots/F9pUrx.png b/assets/design-export/screenshots/F9pUrx.png index ca73eb8ba..37481840a 100644 Binary files a/assets/design-export/screenshots/F9pUrx.png and b/assets/design-export/screenshots/F9pUrx.png differ diff --git a/assets/design-export/screenshots/FOVMf.png b/assets/design-export/screenshots/FOVMf.png index ce1ec9273..c3f4d0ab7 100644 Binary files a/assets/design-export/screenshots/FOVMf.png and b/assets/design-export/screenshots/FOVMf.png differ diff --git a/assets/design-export/screenshots/FyV6E.png b/assets/design-export/screenshots/FyV6E.png index 5373614ab..074c08871 100644 Binary files a/assets/design-export/screenshots/FyV6E.png and b/assets/design-export/screenshots/FyV6E.png differ diff --git a/assets/design-export/screenshots/GR1G9.png b/assets/design-export/screenshots/GR1G9.png index 05b9b6656..8715aa613 100644 Binary files a/assets/design-export/screenshots/GR1G9.png and b/assets/design-export/screenshots/GR1G9.png differ diff --git a/assets/design-export/screenshots/HVhmb.png b/assets/design-export/screenshots/HVhmb.png index 72b0609f8..bc75dbf08 100644 Binary files a/assets/design-export/screenshots/HVhmb.png and b/assets/design-export/screenshots/HVhmb.png differ diff --git a/assets/design-export/screenshots/I38x1.png b/assets/design-export/screenshots/I38x1.png new file mode 100644 index 000000000..4c9f43298 Binary files /dev/null and b/assets/design-export/screenshots/I38x1.png differ diff --git a/assets/design-export/screenshots/IBUqJ.png b/assets/design-export/screenshots/IBUqJ.png new file mode 100644 index 000000000..5de0c54de Binary files /dev/null and b/assets/design-export/screenshots/IBUqJ.png differ diff --git a/assets/design-export/screenshots/J8GzM9.png b/assets/design-export/screenshots/J8GzM9.png new file mode 100644 index 000000000..80c1e6957 Binary files /dev/null and b/assets/design-export/screenshots/J8GzM9.png differ diff --git a/assets/design-export/screenshots/Mp7Ep.png b/assets/design-export/screenshots/Mp7Ep.png index c7071c28b..d70a1152c 100644 Binary files a/assets/design-export/screenshots/Mp7Ep.png and b/assets/design-export/screenshots/Mp7Ep.png differ diff --git a/assets/design-export/screenshots/N2nTf.png b/assets/design-export/screenshots/N2nTf.png new file mode 100644 index 000000000..d5b09935b Binary files /dev/null and b/assets/design-export/screenshots/N2nTf.png differ diff --git a/assets/design-export/screenshots/NKiqf.png b/assets/design-export/screenshots/NKiqf.png index 25b5eb380..e645a564b 100644 Binary files a/assets/design-export/screenshots/NKiqf.png and b/assets/design-export/screenshots/NKiqf.png differ diff --git a/assets/design-export/screenshots/NLu2I.png b/assets/design-export/screenshots/NLu2I.png index 059d02480..e97a7c44e 100644 Binary files a/assets/design-export/screenshots/NLu2I.png and b/assets/design-export/screenshots/NLu2I.png differ diff --git a/assets/design-export/screenshots/OecJ5.png b/assets/design-export/screenshots/OecJ5.png new file mode 100644 index 000000000..f5214ca3a Binary files /dev/null and b/assets/design-export/screenshots/OecJ5.png differ diff --git a/assets/design-export/screenshots/QQtUD.png b/assets/design-export/screenshots/QQtUD.png index a697a766b..af088dac1 100644 Binary files a/assets/design-export/screenshots/QQtUD.png and b/assets/design-export/screenshots/QQtUD.png differ diff --git a/assets/design-export/screenshots/RC3Kf.png b/assets/design-export/screenshots/RC3Kf.png index 98318e540..d2853c958 100644 Binary files a/assets/design-export/screenshots/RC3Kf.png and b/assets/design-export/screenshots/RC3Kf.png differ diff --git a/assets/design-export/screenshots/RpHBg.png b/assets/design-export/screenshots/RpHBg.png index 655303e3c..ee3c959a4 100644 Binary files a/assets/design-export/screenshots/RpHBg.png and b/assets/design-export/screenshots/RpHBg.png differ diff --git a/assets/design-export/screenshots/T70dJ.png b/assets/design-export/screenshots/T70dJ.png new file mode 100644 index 000000000..0a1385a29 Binary files /dev/null and b/assets/design-export/screenshots/T70dJ.png differ diff --git a/assets/design-export/screenshots/UCNBm.png b/assets/design-export/screenshots/UCNBm.png index 49844b87b..f6179cad7 100644 Binary files a/assets/design-export/screenshots/UCNBm.png and b/assets/design-export/screenshots/UCNBm.png differ diff --git a/assets/design-export/screenshots/UMJli.png b/assets/design-export/screenshots/UMJli.png index 23684864c..88ec95830 100644 Binary files a/assets/design-export/screenshots/UMJli.png and b/assets/design-export/screenshots/UMJli.png differ diff --git a/assets/design-export/screenshots/Uzzje.png b/assets/design-export/screenshots/Uzzje.png index 3b2b323c3..236cea100 100644 Binary files a/assets/design-export/screenshots/Uzzje.png and b/assets/design-export/screenshots/Uzzje.png differ diff --git a/assets/design-export/screenshots/W8idqY.png b/assets/design-export/screenshots/W8idqY.png index acb35efab..7d088c358 100644 Binary files a/assets/design-export/screenshots/W8idqY.png and b/assets/design-export/screenshots/W8idqY.png differ diff --git a/assets/design-export/screenshots/Wj0V4.png b/assets/design-export/screenshots/Wj0V4.png index 319ad114e..def7a89ff 100644 Binary files a/assets/design-export/screenshots/Wj0V4.png and b/assets/design-export/screenshots/Wj0V4.png differ diff --git a/assets/design-export/screenshots/WrmU5.png b/assets/design-export/screenshots/WrmU5.png index 854b04c09..288021efe 100644 Binary files a/assets/design-export/screenshots/WrmU5.png and b/assets/design-export/screenshots/WrmU5.png differ diff --git a/assets/design-export/screenshots/Y96CaE.png b/assets/design-export/screenshots/Y96CaE.png index b759e942a..9118ee8bd 100644 Binary files a/assets/design-export/screenshots/Y96CaE.png and b/assets/design-export/screenshots/Y96CaE.png differ diff --git a/assets/design-export/screenshots/YPKbV.png b/assets/design-export/screenshots/YPKbV.png index dc45af589..896a78a7c 100644 Binary files a/assets/design-export/screenshots/YPKbV.png and b/assets/design-export/screenshots/YPKbV.png differ diff --git a/assets/design-export/screenshots/b2JF52.png b/assets/design-export/screenshots/b2JF52.png new file mode 100644 index 000000000..84a09158a Binary files /dev/null and b/assets/design-export/screenshots/b2JF52.png differ diff --git a/assets/design-export/screenshots/dxdEi.png b/assets/design-export/screenshots/dxdEi.png index 26e8b3d2b..582e9a10f 100644 Binary files a/assets/design-export/screenshots/dxdEi.png and b/assets/design-export/screenshots/dxdEi.png differ diff --git a/assets/design-export/screenshots/f1Vga.png b/assets/design-export/screenshots/f1Vga.png index 950e7ec22..f96a58f92 100644 Binary files a/assets/design-export/screenshots/f1Vga.png and b/assets/design-export/screenshots/f1Vga.png differ diff --git a/assets/design-export/screenshots/fK8Bi.png b/assets/design-export/screenshots/fK8Bi.png index dbcb8b823..ba0eb132a 100644 Binary files a/assets/design-export/screenshots/fK8Bi.png and b/assets/design-export/screenshots/fK8Bi.png differ diff --git a/assets/design-export/screenshots/fbBqA.png b/assets/design-export/screenshots/fbBqA.png index 92a2f05d5..725c5adde 100644 Binary files a/assets/design-export/screenshots/fbBqA.png and b/assets/design-export/screenshots/fbBqA.png differ diff --git a/assets/design-export/screenshots/iYUWc.png b/assets/design-export/screenshots/iYUWc.png index 90cae1989..cc65783d9 100644 Binary files a/assets/design-export/screenshots/iYUWc.png and b/assets/design-export/screenshots/iYUWc.png differ diff --git a/assets/design-export/screenshots/j24cXg.png b/assets/design-export/screenshots/j24cXg.png index 95b2ff7cc..b26f3e755 100644 Binary files a/assets/design-export/screenshots/j24cXg.png and b/assets/design-export/screenshots/j24cXg.png differ diff --git a/assets/design-export/screenshots/j9W8A.png b/assets/design-export/screenshots/j9W8A.png index 0a39950d5..b1f88b2e2 100644 Binary files a/assets/design-export/screenshots/j9W8A.png and b/assets/design-export/screenshots/j9W8A.png differ diff --git a/assets/design-export/screenshots/kK8Ji.png b/assets/design-export/screenshots/kK8Ji.png index 09de81fac..bace7cf6c 100644 Binary files a/assets/design-export/screenshots/kK8Ji.png and b/assets/design-export/screenshots/kK8Ji.png differ diff --git a/assets/design-export/screenshots/kKFX9.png b/assets/design-export/screenshots/kKFX9.png index f026c6f89..e695af9e3 100644 Binary files a/assets/design-export/screenshots/kKFX9.png and b/assets/design-export/screenshots/kKFX9.png differ diff --git a/assets/design-export/screenshots/l64bd.png b/assets/design-export/screenshots/l64bd.png index f7611858d..2746dcdab 100644 Binary files a/assets/design-export/screenshots/l64bd.png and b/assets/design-export/screenshots/l64bd.png differ diff --git a/assets/design-export/screenshots/n2qdi.png b/assets/design-export/screenshots/n2qdi.png index 0dcabbf56..9c379dfd1 100644 Binary files a/assets/design-export/screenshots/n2qdi.png and b/assets/design-export/screenshots/n2qdi.png differ diff --git a/assets/design-export/screenshots/nNL3E.png b/assets/design-export/screenshots/nNL3E.png index 9a322e1a7..ff91bdd77 100644 Binary files a/assets/design-export/screenshots/nNL3E.png and b/assets/design-export/screenshots/nNL3E.png differ diff --git a/assets/design-export/screenshots/oyoTs.png b/assets/design-export/screenshots/oyoTs.png index 845eaf963..5eea237f8 100644 Binary files a/assets/design-export/screenshots/oyoTs.png and b/assets/design-export/screenshots/oyoTs.png differ diff --git a/assets/design-export/screenshots/pnAbF.png b/assets/design-export/screenshots/pnAbF.png index 1fbe8c29c..6d8930463 100644 Binary files a/assets/design-export/screenshots/pnAbF.png and b/assets/design-export/screenshots/pnAbF.png differ diff --git a/assets/design-export/screenshots/r139UP.png b/assets/design-export/screenshots/r139UP.png index ea2c73dc3..787ee282f 100644 Binary files a/assets/design-export/screenshots/r139UP.png and b/assets/design-export/screenshots/r139UP.png differ diff --git a/assets/design-export/screenshots/rCQaM.png b/assets/design-export/screenshots/rCQaM.png index 8cdec54f6..538b071e0 100644 Binary files a/assets/design-export/screenshots/rCQaM.png and b/assets/design-export/screenshots/rCQaM.png differ diff --git a/assets/design-export/screenshots/t56aLu.png b/assets/design-export/screenshots/t56aLu.png new file mode 100644 index 000000000..929462c10 Binary files /dev/null and b/assets/design-export/screenshots/t56aLu.png differ diff --git a/assets/design-export/screenshots/tTSdi.png b/assets/design-export/screenshots/tTSdi.png index bbb7bd5b3..16992e8d4 100644 Binary files a/assets/design-export/screenshots/tTSdi.png and b/assets/design-export/screenshots/tTSdi.png differ diff --git a/assets/design-export/screenshots/uMiwd.png b/assets/design-export/screenshots/uMiwd.png index ac69c652c..e289e9cb8 100644 Binary files a/assets/design-export/screenshots/uMiwd.png and b/assets/design-export/screenshots/uMiwd.png differ diff --git a/assets/design-export/screenshots/vUqMl.png b/assets/design-export/screenshots/vUqMl.png index 6824ac7e1..cd3b8c43a 100644 Binary files a/assets/design-export/screenshots/vUqMl.png and b/assets/design-export/screenshots/vUqMl.png differ diff --git a/assets/design-export/screenshots/w34N9.png b/assets/design-export/screenshots/w34N9.png index 1da430701..108748849 100644 Binary files a/assets/design-export/screenshots/w34N9.png and b/assets/design-export/screenshots/w34N9.png differ diff --git a/assets/design-export/screenshots/wqnM9.png b/assets/design-export/screenshots/wqnM9.png new file mode 100644 index 000000000..fa432856d Binary files /dev/null and b/assets/design-export/screenshots/wqnM9.png differ diff --git a/assets/design-export/screenshots/xjVA4.png b/assets/design-export/screenshots/xjVA4.png new file mode 100644 index 000000000..decd0466a Binary files /dev/null and b/assets/design-export/screenshots/xjVA4.png differ diff --git a/assets/design-export/screenshots/xrNf3.png b/assets/design-export/screenshots/xrNf3.png new file mode 100644 index 000000000..87e94f8bc Binary files /dev/null and b/assets/design-export/screenshots/xrNf3.png differ diff --git a/assets/design-export/screenshots/zFiOW.png b/assets/design-export/screenshots/zFiOW.png index 2e21269d2..fa6031a80 100644 Binary files a/assets/design-export/screenshots/zFiOW.png and b/assets/design-export/screenshots/zFiOW.png differ diff --git a/assets/design-export/screenshots/zj5a3.png b/assets/design-export/screenshots/zj5a3.png index 3522676de..84e18b15f 100644 Binary files a/assets/design-export/screenshots/zj5a3.png and b/assets/design-export/screenshots/zj5a3.png differ diff --git a/design.pen b/design.pen index d43856582..9bc6409c4 100644 --- a/design.pen +++ b/design.pen @@ -1027,7 +1027,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -1059,7 +1059,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -1101,7 +1101,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, @@ -15344,7 +15349,7 @@ "id": "U9yGXG", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -15369,7 +15374,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -16599,7 +16604,7 @@ "id": "qnMPd", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -16624,7 +16629,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -17304,7 +17309,7 @@ "id": "NZNG4", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -17329,7 +17334,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -18790,7 +18795,7 @@ "id": "Y5o8fo", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -18815,7 +18820,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -19355,7 +19360,7 @@ "id": "PlMgh", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -19380,7 +19385,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -20680,7 +20685,7 @@ "id": "vudRw", "name": "Central management explanation", "fill": "$muted", - "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.4285714286, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -22022,7 +22027,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", @@ -22362,7 +22367,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", @@ -23432,7 +23437,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", @@ -23809,7 +23814,8 @@ "id": "j9W8A", "x": 0, "y": 65232, - "name": "Screen/Cluster Settings", + "name": "Legacy/Cluster Settings — Superseded", + "context": "Superseded by Cluster Details Configuration and Nodes screens. Preserved for historical install-time storage states.", "theme": { "semantic": "dark" }, @@ -28904,7 +28910,7 @@ "id": "LLHgj", "name": "Central management explanation", "fill": "$muted", - "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.4285714286, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -30001,7 +30007,8 @@ "id": "RC3Kf", "x": 0, "y": 82231, - "name": "Screen/Admin Settings — Backup destinations", + "name": "Legacy/Admin Settings — Backup destinations — Moved", + "context": "Superseded by Cluster Details Overview, Nodes and Configuration. Backup destinations and registration removal are scoped to cluster Configuration.", "theme": { "semantic": "dark" }, @@ -30106,15 +30113,6 @@ }, "DpMnt": { "fill": "$muted" - }, - "fOAMd": { - "fill": "$surface/secondary" - }, - "TWq2N": { - "fill": "$foreground/foreground" - }, - "E3ECJ": { - "fill": "$foreground/foreground" } } }, @@ -34385,7 +34383,7 @@ "id": "jrzYL", "name": "Central management explanation", "fill": "$muted", - "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.4285714286, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -46742,7 +46740,8 @@ "id": "dxdEi", "x": 1640, "y": 65232, - "name": "Screen/Cluster Settings — Default storage class", + "name": "Legacy/Cluster Settings — Default storage — Superseded", + "context": "Superseded by Cluster Details Configuration and Nodes screens. Preserved for historical install-time storage states.", "theme": { "semantic": "dark" }, @@ -62510,7 +62509,7 @@ "content": "Servers" }, "VbHwU": { - "content": "Manage your game servers across all authorized locations." + "content": "Manage your game servers across all authorized clusters." }, "TgdLz": { "type": "frame", @@ -65868,7 +65867,7 @@ "content": "Servers" }, "VbHwU": { - "content": "Manage your game servers across all authorized locations." + "content": "Manage your game servers across all authorized clusters." }, "TgdLz": { "children": [ @@ -70373,7 +70372,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -70405,7 +70404,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -70447,7 +70446,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, @@ -76232,7 +76236,7 @@ "id": "VXTz6", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -76257,7 +76261,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -88074,7 +88078,7 @@ "id": "UCNBm", "x": 3280, "y": 14602, - "name": "Screen/Servers — Location filters open", + "name": "Screen/Servers — Cluster filters open", "context": "Implemented at 8e08a60f. Filter fields are drafts; Apply commits, Clear resets drafts, dismissal discards edits. Server choices retain location and namespace.", "theme": { "semantic": "dark" @@ -88186,7 +88190,7 @@ "content": "Servers" }, "VbHwU": { - "content": "Manage your game servers across all authorized locations." + "content": "Manage your game servers across all authorized clusters." }, "TgdLz": { "children": [ @@ -89291,7 +89295,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", @@ -89642,7 +89646,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", @@ -90729,7 +90733,7 @@ "content": "Backups" }, "VbHwU": { - "content": "Snapshots, schedules, and restores across your authorized locations." + "content": "Snapshots, schedules, and restores across your authorized clusters." }, "TgdLz": { "type": "frame", @@ -91116,7 +91120,7 @@ "id": "YPKbV", "x": 3280, "y": 12822, - "name": "Screen/Dashboard — Partial location coverage", + "name": "Screen/Dashboard — Partial cluster coverage", "context": "Counts cover returned resources only. Unknown inventory and player metrics use an em dash, never zero.", "theme": { "semantic": "dark" @@ -91173,7 +91177,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -91205,7 +91209,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -91303,7 +91307,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, @@ -93021,7 +93030,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Snapshots, schedules, and restores across your authorized locations.", + "content": "Snapshots, schedules, and restores across your authorized clusters.", "lineHeight": 1.4, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -93126,7 +93135,7 @@ "id": "iYUWc", "x": 0, "y": 63932, - "name": "Screen/Clusters — Registered locations", + "name": "Screen/Clusters — Registered clusters", "theme": { "semantic": "dark" }, @@ -93204,7 +93213,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Registered clusters, connectivity and node inventory." }, "TgdLz": { "enabled": false @@ -93303,13 +93312,13 @@ "id": "X8IeOl", "type": "ref", "ref": "cb4rt", - "name": "View servers", + "name": "Open cluster", "descendants": { "Xpn7K": { "enabled": false }, "U6q13B": { - "content": "View servers" + "content": "Open cluster" } } }, @@ -93395,13 +93404,13 @@ "id": "YRYRA", "type": "ref", "ref": "cb4rt", - "name": "View servers", + "name": "Open cluster", "descendants": { "Xpn7K": { "enabled": false }, "U6q13B": { - "content": "View servers" + "content": "Open cluster" } } }, @@ -96756,7 +96765,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -96788,7 +96797,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -96830,7 +96839,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, @@ -97479,7 +97493,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "children": [ @@ -97511,7 +97525,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -97553,7 +97567,12 @@ "type": "ref", "ref": "Y96CaE", "name": "Cluster Resources Card", - "width": "fill_container" + "width": "fill_container", + "descendants": { + "RD2NC": { + "content": "View nodes" + } + } } ] }, @@ -100428,7 +100447,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Administration of registered clusters, connectivity and node inventory." }, "TgdLz": { "type": "frame", @@ -100589,7 +100608,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Registered clusters, connectivity and node inventory." }, "TgdLz": { "type": "frame", @@ -100740,14 +100759,14 @@ "id": "i0uSmF", "type": "ref", "ref": "cb4rt", - "name": "View servers", + "name": "Open cluster", "opacity": 1, "descendants": { "Xpn7K": { "enabled": false }, "U6q13B": { - "content": "View servers" + "content": "Open cluster" } } }, @@ -100765,21 +100784,6 @@ "content": "View nodes" } } - }, - { - "id": "W1073i", - "type": "ref", - "ref": "i6gfu", - "name": "Remove", - "opacity": 1, - "descendants": { - "FCYQ9": { - "enabled": false - }, - "GqVun": { - "content": "Remove" - } - } } ] } @@ -100877,14 +100881,14 @@ "id": "U7jY7p", "type": "ref", "ref": "cb4rt", - "name": "View servers", + "name": "Open cluster", "opacity": 1, "descendants": { "Xpn7K": { "enabled": false }, "U6q13B": { - "content": "View servers" + "content": "Open cluster" } } }, @@ -100902,21 +100906,6 @@ "content": "View nodes" } } - }, - { - "id": "Bd9ic", - "type": "ref", - "ref": "i6gfu", - "name": "Remove", - "opacity": 1, - "descendants": { - "FCYQ9": { - "enabled": false - }, - "GqVun": { - "content": "Remove" - } - } } ] } @@ -100935,8 +100924,8 @@ "id": "Be7ZW", "x": 4920, "y": 63932, - "name": "Screen/Standalone — Cluster inventory (No selection)", - "context": "PR #630. Selection is explicit and only registered remotes are listed. No implicit local target; no workload requests until installation and selected target are known.", + "name": "Legacy/Standalone — Cluster inventory selection — Superseded", + "context": "Superseded by Cluster Details Overview, Nodes and Configuration. Backup destinations and registration removal are scoped to cluster Configuration.", "theme": { "semantic": "dark" }, @@ -101548,7 +101537,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Administration of registered clusters, connectivity and node inventory." }, "TgdLz": { "type": "frame", @@ -101651,8 +101640,8 @@ "id": "HVhmb", "x": 8200, "y": 63932, - "name": "Screen/Standalone — Remove cluster", - "context": "Removal errors stay inside the confirmation and reset when dismissed or reopened. Successful removal clears a matching current selection to empty without selecting local or another remote.", + "name": "Legacy/Clusters list — Remove registration — Moved", + "context": "Superseded by Cluster Details Overview, Nodes and Configuration. Backup destinations and registration removal are scoped to cluster Configuration.", "theme": { "semantic": "dark" }, @@ -101733,7 +101722,7 @@ "content": "Clusters" }, "VbHwU": { - "content": "Administration of registered locations, connectivity and node inventory." + "content": "Administration of registered clusters, connectivity and node inventory." }, "TgdLz": { "type": "frame", @@ -102645,8 +102634,8 @@ "id": "D9QYZ", "x": 1640, "y": 82231, - "name": "Screen/Standalone — Settings · Backup destinations", - "context": "PR #630 standalone installation. No local Kubernetes metadata or local workload fallback. Master-key storage and offline rotation have no dashboard controls.", + "name": "Legacy/Standalone — Backup destinations — Moved", + "context": "Superseded by Cluster Details Overview, Nodes and Configuration. Backup destinations and registration removal are scoped to cluster Configuration.", "theme": { "semantic": "dark" }, @@ -102727,7 +102716,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -102785,15 +102774,6 @@ }, "DpMnt": { "fill": "$muted" - }, - "fOAMd": { - "fill": "$surface/secondary" - }, - "TWq2N": { - "fill": "$foreground/foreground" - }, - "E3ECJ": { - "fill": "$foreground/foreground" } } }, @@ -103010,7 +102990,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -103293,7 +103273,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -103553,7 +103533,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -103883,7 +103863,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", + "content": "Central management. This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", "lineHeight": 1.5, "fontFamily": "$typography/font-sans", "fontSize": 14, @@ -107942,7 +107922,7 @@ "width": "fill_container", "descendants": { "VbHwU": { - "content": "Your game servers and resources across authorized locations." + "content": "Your game servers and resources across authorized clusters." }, "TgdLz": { "enabled": false, @@ -107977,7 +107957,7 @@ "context": "Optional location filter; All locations combines authorized inventory.", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -110651,7 +110631,7 @@ "id": "lZ6pP", "type": "ref", "ref": "W8XiN", - "name": "navCluster", + "name": "navClusters", "width": "fill_container", "cornerRadius": 6, "padding": [ @@ -110671,6 +110651,30 @@ } } }, + { + "id": "VqyiS", + "type": "ref", + "ref": "W8XiN", + "name": "navNodes", + "width": "fill_container", + "cornerRadius": 6, + "padding": [ + 8, + 12 + ], + "gap": 12, + "height": 36, + "alignItems": "center", + "fill": "#00000000", + "descendants": { + "Sr2Q5": { + "icon": "server" + }, + "Ra9NK": { + "content": "Nodes" + } + } + }, { "id": "dNsbz", "type": "ref", @@ -112685,41 +112689,6 @@ } ] }, - { - "type": "frame", - "id": "fOAMd", - "name": "snBackups", - "width": "fill_container", - "height": 36, - "cornerRadius": 6, - "gap": 8, - "padding": [ - 8, - 12 - ], - "alignItems": "center", - "children": [ - { - "type": "icon", - "id": "TWq2N", - "width": 16, - "height": 16, - "icon": "archive", - "library": "lucide", - "weight": 400, - "fill": "$muted" - }, - { - "type": "text", - "id": "E3ECJ", - "fill": "$muted", - "content": "Backup destinations", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, { "type": "frame", "id": "OUh8J", @@ -118519,7 +118488,7 @@ "id": "XUciA", "name": "Location label", "fill": "$muted", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -118532,7 +118501,7 @@ "width": "fill_container", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -118802,7 +118771,7 @@ "id": "WqfpR", "name": "Location label", "fill": "$muted", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -118815,7 +118784,7 @@ "width": "fill_container", "descendants": { "cFsFk": { - "content": "All locations", + "content": "All clusters", "fill": "$foreground/foreground" } } @@ -124468,6 +124437,7 @@ "x": 3889, "y": 7617, "name": "Gameplane/Cluster Resources Card", + "context": "View nodes opens the all-cluster inventory at /nodes and preserves the Dashboard cluster filter.", "reusable": true, "width": "fill_container(546)", "fill": "$surface/surface", @@ -124512,7 +124482,7 @@ "id": "RD2NC", "name": "link", "fill": "$accent/accent", - "content": "View cluster", + "content": "View nodes", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -130511,7 +130481,7 @@ "id": "mtHaM", "name": "Location label", "fill": "$foreground/foreground", - "content": "Location", + "content": "Cluster", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -130536,7 +130506,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Changing location starts a fresh draft. The chosen location handles all creation steps.", + "content": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal" @@ -133580,6 +133550,4728 @@ ] } ] + }, + { + "type": "frame", + "id": "I38x1", + "x": 3049, + "y": 949, + "name": "Gameplane/Node Inventory Table", + "reusable": true, + "clip": true, + "width": 1132, + "fill": "$surface/secondary", + "cornerRadius": 20, + "layout": "vertical", + "padding": [ + 0, + 4, + 4, + 4 + ], + "children": [ + { + "type": "frame", + "id": "u0ZgPI", + "name": "Sortable column headers", + "width": "fill_container", + "height": 44, + "alignItems": "center", + "children": [ + { + "id": "R8zOyV", + "type": "ref", + "ref": "DYYKO", + "name": "Node ↑", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Node ↑" + } + } + }, + { + "id": "MEgE7", + "type": "ref", + "ref": "DYYKO", + "name": "Cluster ↕", + "width": 160, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Cluster ↕" + } + } + }, + { + "id": "lh3Jh", + "type": "ref", + "ref": "DYYKO", + "name": "Status ↕", + "width": 110, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Status ↕" + } + } + }, + { + "id": "b65Z84", + "type": "ref", + "ref": "DYYKO", + "name": "CPU ↕", + "width": 130, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "CPU ↕" + } + } + }, + { + "id": "JT6pI", + "type": "ref", + "ref": "DYYKO", + "name": "Memory ↕", + "width": 150, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Memory ↕" + } + } + }, + { + "id": "bDTM0", + "type": "ref", + "ref": "DYYKO", + "name": "Pods ↕", + "width": 110, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Pods ↕" + } + } + }, + { + "id": "v81JU", + "type": "ref", + "ref": "DYYKO", + "name": "Uptime ↕", + "width": 95, + "height": "fill_container", + "descendants": { + "SIu74": { + "content": "Uptime ↕" + } + } + } + ] + }, + { + "type": "frame", + "id": "u91Ykc", + "name": "Node row kubelab-control", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "WIEOH", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ kubelab-control", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "kubelab-control", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "eKC7K", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Local", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Local", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "wzVRy", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ Ready", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Ready", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "YlI00", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ 24% · 1.9 / 8", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "24% · 1.9 / 8", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "TxOIO", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ 42% · 6.7 / 16 GiB", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "42% · 6.7 / 16 GiB", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "T1qux", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ 18 / 110", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "18 / 110", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "m6IC5", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ 12d 4h", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "12d 4h", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + }, + { + "type": "frame", + "id": "EmG5k", + "name": "Node row kubelab-worker-1", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "IBkte", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ kubelab-worker-1", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "kubelab-worker-1", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "Pg190", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Local", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Local", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "WNTFz", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ Ready", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Ready", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "iS3O8", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ 56% · 4.5 / 8", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "56% · 4.5 / 8", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "ngIDi", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ 65% · 10.4 / 16 GiB", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "65% · 10.4 / 16 GiB", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "Mz5Rh", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ 24 / 110", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "24 / 110", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "nv9Ri", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ 12d 4h", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "12d 4h", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + }, + { + "type": "frame", + "id": "p2FxaN", + "name": "Node row kubelab-worker-2", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "F5r32J", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ kubelab-worker-2", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "kubelab-worker-2", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "P4LYb", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Local", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Local", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "H9OueY", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ NotReady", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "NotReady", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "v4TV8j", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ Unknown", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Unknown", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "G8B8YF", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ Unknown", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Unknown", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "E7e2f", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ Unknown", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Unknown", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "Ur3ZP", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ Unknown", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Unknown", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + }, + { + "type": "frame", + "id": "LUWVT", + "name": "Node row remote-worker-1", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "oKp79", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ remote-worker-1", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "remote-worker-1", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "qm8e8", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Remote lab", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Remote lab", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "fT3QE", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ Ready", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Ready", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "VGdRN", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ 31% · 2.5 / 8", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "31% · 2.5 / 8", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "qqcxe", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ 28% · 4.5 / 16 GiB", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "28% · 4.5 / 16 GiB", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "A58ok", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ 14 / 110", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "14 / 110", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "nRX4f", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ 5d 2h", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "5d 2h", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + }, + { + "type": "frame", + "id": "g269sj", + "name": "Node row remote-worker-2", + "width": "fill_container", + "height": 64, + "fill": "$surface/surface", + "children": [ + { + "id": "U5fdeZ", + "type": "ref", + "ref": "TDRJE", + "name": "Node ↑ remote-worker-2", + "width": "fill_container", + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "remote-worker-2", + "fontSize": 13, + "fontWeight": "500", + "fontFamily": "$typography/font-mono", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "nweDg", + "type": "ref", + "ref": "TDRJE", + "name": "Cluster ↕ Remote lab", + "width": 160, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Remote lab", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "dp3cy", + "type": "ref", + "ref": "TDRJE", + "name": "Status ↕ Ready", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "Ready", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "PMlmq", + "type": "ref", + "ref": "TDRJE", + "name": "CPU ↕ 18% · 1.4 / 8", + "width": 130, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "18% · 1.4 / 8", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "WRazE", + "type": "ref", + "ref": "TDRJE", + "name": "Memory ↕ 36% · 5.8 / 16 GiB", + "width": 150, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "36% · 5.8 / 16 GiB", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "mXEif", + "type": "ref", + "ref": "TDRJE", + "name": "Pods ↕ 12 / 110", + "width": 110, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "12 / 110", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + }, + { + "id": "y7535Y", + "type": "ref", + "ref": "TDRJE", + "name": "Uptime ↕ 5d 2h", + "width": 95, + "height": "fill_container", + "descendants": { + "HNAOu": { + "content": "5d 2h", + "fontSize": 13, + "fontWeight": "normal", + "fontFamily": "$typography/font-sans", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container" + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "BdmAW", + "x": 11480, + "y": 63932, + "name": "Screen/Nodes — All clusters", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "grYDZ", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "VqyiS": { + "fill": "$accent/soft" + }, + "VqyiS/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "VqyiS/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "gVOTp", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "r4xdRg", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Nodes" + } + } + }, + { + "type": "frame", + "id": "q7GH0", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "ofhIo", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Nodes" + }, + "VbHwU": { + "content": "Health and capacity across your authorized clusters." + }, + "TgdLz": { + "id": "kEFDn", + "type": "ref", + "ref": "cb4rt", + "name": "Add node", + "descendants": { + "U6q13B": { + "content": "Add node" + } + } + } + } + }, + { + "type": "frame", + "id": "F9AoB3", + "name": "Node inventory totals", + "width": "fill_container", + "gap": 12, + "children": [ + { + "id": "eMCSg", + "type": "ref", + "ref": "ZWcwn", + "name": "NODES READY", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "NODES READY" + }, + "QdcEm": { + "icon": "server" + }, + "vj2Ru": { + "content": "4 / 5" + }, + "h0n7zo": { + "content": "returned nodes" + } + } + }, + { + "id": "Xs472", + "type": "ref", + "ref": "ZWcwn", + "name": "CLUSTERS REPORTING", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "CLUSTERS REPORTING" + }, + "QdcEm": { + "icon": "network" + }, + "vj2Ru": { + "content": "2 / 3" + }, + "h0n7zo": { + "content": "one cluster unavailable" + } + } + }, + { + "id": "OOeYI", + "type": "ref", + "ref": "ZWcwn", + "name": "CPU USAGE", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "CPU USAGE" + }, + "QdcEm": { + "icon": "cpu" + }, + "vj2Ru": { + "content": "32%" + }, + "h0n7zo": { + "content": "4 nodes reporting" + } + } + }, + { + "id": "Jk6tu", + "type": "ref", + "ref": "ZWcwn", + "name": "MEMORY USAGE", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "MEMORY USAGE" + }, + "QdcEm": { + "icon": "memory-stick" + }, + "vj2Ru": { + "content": "43%" + }, + "h0n7zo": { + "content": "4 nodes reporting" + } + } + } + ] + }, + { + "id": "f42Iv", + "type": "ref", + "ref": "b2JF52", + "name": "Search and Filters", + "width": "fill_container" + }, + { + "type": "frame", + "id": "otKLx", + "name": "Partial inventory notice", + "width": "fill_container", + "fill": "$warning/soft", + "cornerRadius": 12, + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "y4htvj", + "name": "Coverage warning", + "fill": "$warning/soft-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Partial inventory · West is unavailable. Showing 5 returned nodes from 2 clusters.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "oIZu6", + "type": "ref", + "ref": "I38x1", + "name": "All authorized nodes", + "width": "fill_container" + }, + { + "type": "text", + "id": "dpgR1", + "name": "Inventory count", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5 nodes shown · Select a cluster name to open its details.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wqnM9", + "x": 13120, + "y": 63932, + "name": "Screen/Cluster Details — Overview", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "HIs18", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "xGoph", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "n0G3M", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Remote lab" + } + } + }, + { + "type": "frame", + "id": "FDFsb", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "szMIY", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Remote lab" + }, + "VbHwU": { + "content": "remote-lab · Registered workload cluster" + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "A3iJ6", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "slJKl", + "type": "ref", + "ref": "CEWo3", + "name": "Overview tab", + "descendants": { + "YUR8N": { + "content": "Overview" + } + } + }, + { + "id": "Redwr", + "type": "ref", + "ref": "qAFbJ", + "name": "Nodes tab", + "descendants": { + "VcxMI": { + "content": "Nodes" + } + } + }, + { + "id": "nohtR", + "type": "ref", + "ref": "qAFbJ", + "name": "Configuration tab", + "descendants": { + "VcxMI": { + "content": "Configuration" + } + } + } + ] + }, + { + "type": "frame", + "id": "i7XRlr", + "name": "Cluster summary", + "width": "fill_container", + "gap": 16, + "children": [ + { + "id": "jNKF5", + "type": "ref", + "ref": "ZWcwn", + "name": "API CONNECTIVITY", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "API CONNECTIVITY" + }, + "QdcEm": { + "icon": "network" + }, + "vj2Ru": { + "content": "Healthy", + "fontSize": 22 + }, + "h0n7zo": { + "content": "Last checked just now" + } + } + }, + { + "id": "pAEH8", + "type": "ref", + "ref": "ZWcwn", + "name": "NODES READY", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "NODES READY" + }, + "QdcEm": { + "icon": "server" + }, + "vj2Ru": { + "content": "2 / 2", + "fontSize": 22 + }, + "h0n7zo": { + "content": "inventory available" + } + } + }, + { + "id": "MB4N2", + "type": "ref", + "ref": "ZWcwn", + "name": "KUBERNETES", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "KUBERNETES" + }, + "QdcEm": { + "icon": "boxes" + }, + "vj2Ru": { + "content": "v1.35.5", + "fontSize": 22 + }, + "h0n7zo": { + "content": "reported server version" + } + } + } + ] + }, + { + "type": "frame", + "id": "MLeU1", + "name": "Connection", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "KUqWG", + "name": "Connection heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Connection", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "diCa2", + "name": "Connection description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "API status reports Kubernetes connectivity. Game server and gateway health are shown with each server.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vSQ4P", + "name": "Registration identity", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Cluster ID: remote-lab", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "F82Y7z", + "name": "Cluster destinations", + "width": "fill_container", + "gap": 12, + "children": [ + { + "id": "QQJuU", + "type": "ref", + "ref": "cb4rt", + "name": "View servers", + "descendants": { + "Xpn7K": { + "icon": "server" + }, + "U6q13B": { + "content": "View servers" + } + } + }, + { + "id": "zwS4R", + "type": "ref", + "ref": "i6gfu", + "name": "View nodes", + "descendants": { + "FCYQ9": { + "icon": "layers" + }, + "GqVun": { + "content": "View nodes" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "J8GzM9", + "x": 14760, + "y": 63932, + "name": "Screen/Cluster Details — Nodes", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "Q0pqx", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "S1Pit", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "v7q5NI", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Local › Nodes" + } + } + }, + { + "type": "frame", + "id": "bAyuY", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "k1ONI", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Local" + }, + "VbHwU": { + "content": "local · Built-in cluster" + }, + "TgdLz": { + "id": "ZrORy", + "type": "ref", + "ref": "cb4rt", + "name": "Add node", + "descendants": { + "U6q13B": { + "content": "Add node" + } + } + } + } + }, + { + "type": "frame", + "id": "enFs0", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "KFwE8", + "type": "ref", + "ref": "qAFbJ", + "name": "Overview tab", + "descendants": { + "VcxMI": { + "content": "Overview" + } + } + }, + { + "id": "R9kSft", + "type": "ref", + "ref": "CEWo3", + "name": "Nodes tab", + "descendants": { + "YUR8N": { + "content": "Nodes" + } + } + }, + { + "id": "C4iex", + "type": "ref", + "ref": "qAFbJ", + "name": "Configuration tab", + "descendants": { + "VcxMI": { + "content": "Configuration" + } + } + } + ] + }, + { + "id": "FBDxe", + "type": "ref", + "ref": "b2JF52", + "name": "Search and Filters — fixed Local cluster", + "width": "fill_container", + "context": "Popover contains Status only. Cluster: Local is fixed and does not count as an active filter.", + "descendants": { + "LMf8L": { + "enabled": true + } + } + }, + { + "type": "text", + "id": "odI4L", + "name": "Cluster node total", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "2 / 3 nodes ready · 3 nodes shown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "C6ZG2", + "type": "ref", + "ref": "I38x1", + "name": "Shared inventory table scoped to Local", + "width": "fill_container", + "descendants": { + "LUWVT": { + "enabled": false, + "width": 1124 + }, + "g269sj": { + "enabled": false, + "width": 1124 + } + } + }, + { + "id": "gxSZu", + "type": "ref", + "ref": "i6gfu", + "name": "View all nodes", + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "View all nodes" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "OecJ5", + "x": 16400, + "y": 63932, + "name": "Screen/Cluster Details — Configuration — Local", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1060, + "fill": "$background/background", + "children": [ + { + "id": "d4Ky9", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "Ydtdl", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "aBWaW", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Local › Configuration" + } + } + }, + { + "type": "frame", + "id": "OP0f5", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "oBQbJ", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Local" + }, + "VbHwU": { + "content": "local · Built-in cluster" + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "sR7uz", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "U25Za", + "type": "ref", + "ref": "qAFbJ", + "name": "Overview tab", + "descendants": { + "VcxMI": { + "content": "Overview" + } + } + }, + { + "id": "o9q64K", + "type": "ref", + "ref": "qAFbJ", + "name": "Nodes tab", + "descendants": { + "VcxMI": { + "content": "Nodes" + } + } + }, + { + "id": "gjz7s", + "type": "ref", + "ref": "CEWo3", + "name": "Configuration tab", + "descendants": { + "YUR8N": { + "content": "Configuration" + } + } + } + ] + }, + { + "type": "frame", + "id": "VzNfk", + "name": "Access", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "p5wOE", + "name": "Access heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Access", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "kOhUH", + "name": "Access description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Download a kubeconfig for this cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "a3VJJN", + "type": "ref", + "ref": "i6gfu", + "name": "Download kubeconfig", + "descendants": { + "FCYQ9": { + "icon": "download" + }, + "GqVun": { + "content": "Download kubeconfig" + } + } + }, + { + "type": "text", + "id": "MU5Tb", + "name": "Enrollment link", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "To join a machine, use Add node on the Nodes tab.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LgatB", + "name": "Storage", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "A9tT3", + "name": "Storage heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Storage", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "zYT9I", + "name": "Storage description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Set at install time. These values are read-only.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "iXn3F", + "name": "Storage class row", + "width": "fill_container", + "gap": 24, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "B6kNt", + "name": "Storage class label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Game data storage class", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Q3dDF", + "name": "Storage class value", + "fill": "$foreground/foreground", + "content": "fast-nvme", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "WegCt", + "name": "Backup destinations", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "ySXQv", + "name": "Backup destinations heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Backup destinations", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "cijZ0", + "name": "Backup destinations description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Repositories used by servers running on this cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "XPTLB", + "name": "Backup destination row", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "C0j1T", + "name": "Repository details", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "hnKtC", + "name": "Repository name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "nightly-backups", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "CoDeX", + "name": "Repository URL", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "s3:s3.example.com/gameplane", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "QHmLE", + "type": "ref", + "ref": "i6gfu", + "name": "Delete destination", + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Delete" + } + } + } + ] + }, + { + "id": "b4j6s", + "type": "ref", + "ref": "cb4rt", + "name": "Add backup destination", + "descendants": { + "U6q13B": { + "content": "Add destination" + } + } + } + ] + }, + { + "type": "text", + "id": "M0m68N", + "name": "Built-in cluster removal", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The built-in local cluster cannot be removed.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "t56aLu", + "x": 18040, + "y": 63932, + "name": "Screen/Cluster Details — Configuration — Remote", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1060, + "fill": "$background/background", + "children": [ + { + "id": "qjaPk", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "oyYlV", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "Twrfz", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Remote lab › Configuration" + } + } + }, + { + "type": "frame", + "id": "iAnOJ", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "diWXH", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Remote lab" + }, + "VbHwU": { + "content": "remote-lab · Registered workload cluster" + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "kQWhP", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "Mc5Lj", + "type": "ref", + "ref": "qAFbJ", + "name": "Overview tab", + "descendants": { + "VcxMI": { + "content": "Overview" + } + } + }, + { + "id": "GHUO8", + "type": "ref", + "ref": "qAFbJ", + "name": "Nodes tab", + "descendants": { + "VcxMI": { + "content": "Nodes" + } + } + }, + { + "id": "XmsVL", + "type": "ref", + "ref": "CEWo3", + "name": "Configuration tab", + "descendants": { + "YUR8N": { + "content": "Configuration" + } + } + } + ] + }, + { + "type": "frame", + "id": "P0wXMc", + "name": "Access", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "E9Yw2", + "name": "Access heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Access", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "pgCgw", + "name": "Access description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Node enrollment and kubeconfig downloads are managed at this remote cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Q90Zz", + "name": "Storage", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "VctHF", + "name": "Storage heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Storage", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "kC6jR", + "name": "Storage description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Storage settings are managed on this cluster. Install-time storage values are available only for the built-in local cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dalvF", + "name": "Backup destinations", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "xR3KG", + "name": "Backup destinations heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Backup destinations", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "L42aR", + "name": "Backup destinations description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Repositories used by servers running on Remote lab.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "N5Xfuf", + "name": "Empty backup destinations", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No backup destinations configured. Add one to enable snapshots.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "hGg1x", + "type": "ref", + "ref": "cb4rt", + "name": "Add remote backup destination", + "descendants": { + "U6q13B": { + "content": "Add destination" + } + } + } + ] + }, + { + "type": "frame", + "id": "iue9v", + "name": "Remove cluster", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$danger/soft", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "aUbTB", + "name": "Remove cluster heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remove cluster", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "M1ms36", + "name": "Remove cluster description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remove this panel’s connection to Remote lab. Running workloads remain on the cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "Bfl2v", + "type": "ref", + "ref": "f3m06I", + "name": "Remove cluster registration", + "descendants": { + "sCPUE": { + "icon": "trash-2" + }, + "RY93S": { + "content": "Remove cluster" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "IBUqJ", + "x": 19680, + "y": 63932, + "name": "Screen/Nodes — Add node target", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 900, + "fill": "$background/background", + "children": [ + { + "id": "DJiPT", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "VqyiS": { + "fill": "$accent/soft" + }, + "VqyiS/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "VqyiS/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "srews", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "JK792", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Nodes" + } + } + }, + { + "type": "frame", + "id": "SrXXC", + "name": "Body", + "opacity": 0.24, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "yW6ns", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Nodes" + }, + "VbHwU": { + "content": "Health and capacity across your authorized clusters." + }, + "TgdLz": { + "id": "U4n9S", + "type": "ref", + "ref": "cb4rt", + "name": "Add node", + "descendants": { + "U6q13B": { + "content": "Add node" + } + } + } + } + }, + { + "type": "frame", + "id": "VrP1N", + "name": "Node inventory totals", + "width": "fill_container", + "gap": 12, + "children": [ + { + "id": "tquyo", + "type": "ref", + "ref": "ZWcwn", + "name": "NODES READY", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "NODES READY" + }, + "QdcEm": { + "icon": "server" + }, + "vj2Ru": { + "content": "4 / 5" + }, + "h0n7zo": { + "content": "returned nodes" + } + } + }, + { + "id": "dapf3", + "type": "ref", + "ref": "ZWcwn", + "name": "CLUSTERS REPORTING", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "CLUSTERS REPORTING" + }, + "QdcEm": { + "icon": "network" + }, + "vj2Ru": { + "content": "2 / 3" + }, + "h0n7zo": { + "content": "one cluster unavailable" + } + } + }, + { + "id": "xjon9", + "type": "ref", + "ref": "ZWcwn", + "name": "CPU USAGE", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "CPU USAGE" + }, + "QdcEm": { + "icon": "cpu" + }, + "vj2Ru": { + "content": "32%" + }, + "h0n7zo": { + "content": "4 nodes reporting" + } + } + }, + { + "id": "jRaMd", + "type": "ref", + "ref": "ZWcwn", + "name": "MEMORY USAGE", + "width": "fill_container", + "descendants": { + "Nd4Wf": { + "content": "MEMORY USAGE" + }, + "QdcEm": { + "icon": "memory-stick" + }, + "vj2Ru": { + "content": "43%" + }, + "h0n7zo": { + "content": "4 nodes reporting" + } + } + } + ] + }, + { + "id": "ptSqT", + "type": "ref", + "ref": "b2JF52", + "name": "Search and Filters", + "width": "fill_container" + }, + { + "type": "frame", + "id": "m3CJ2", + "name": "Partial inventory notice", + "width": "fill_container", + "fill": "$warning/soft", + "cornerRadius": 12, + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "LtwZm", + "name": "Coverage warning", + "fill": "$warning/soft-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Partial inventory · West is unavailable. Showing 5 returned nodes from 2 clusters.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "G1Vny", + "type": "ref", + "ref": "I38x1", + "name": "All authorized nodes", + "width": "fill_container" + }, + { + "type": "text", + "id": "DdpCr", + "name": "Inventory count", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5 nodes shown · Select a cluster name to open its details.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "mQHbL", + "layoutPosition": "absolute", + "x": 480, + "y": 220, + "name": "Add node target dialog", + "width": 600, + "fill": "$surface/surface", + "cornerRadius": 24, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 20, + "padding": 28, + "children": [ + { + "type": "text", + "id": "mGk1c", + "name": "Add node title", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Add node", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 22, + "fontWeight": "600" + }, + { + "type": "text", + "id": "F4bIn", + "name": "Add node instructions", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Choose the cluster this machine should join.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "s3Xk1E", + "type": "ref", + "ref": "AT7ya", + "name": "Target cluster selection", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "Select a cluster…" + } + } + }, + { + "type": "text", + "id": "rRscs", + "name": "Cluster enrollment capability", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Local enrollment is available when cluster operations are enabled. Remote clusters provide their own enrollment instructions.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nFrXM", + "name": "Enrollment dialog actions", + "width": "fill_container", + "gap": 12, + "justifyContent": "end", + "children": [ + { + "id": "HSLjL", + "type": "ref", + "ref": "i6gfu", + "name": "Cancel", + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Cancel" + } + } + }, + { + "id": "JsWf8", + "type": "ref", + "ref": "cb4rt", + "name": "Continue after target selection", + "opacity": 0.45, + "descendants": { + "Xpn7K": { + "enabled": false + }, + "U6q13B": { + "content": "Continue" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "N2nTf", + "x": 21280, + "y": 63932, + "name": "Screen/Mobile — Nodes — All clusters", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 390, + "height": 1620, + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "cSkTa", + "type": "ref", + "ref": "fGbVF", + "name": "Mobile navigation", + "width": "fill_container", + "descendants": { + "WOloN": { + "content": "Nodes" + } + } + }, + { + "type": "frame", + "id": "v7IOcE", + "name": "Mobile node inventory", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": 16, + "children": [ + { + "type": "text", + "id": "xspYX", + "name": "Page description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Health and capacity across clusters.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "anmfc", + "name": "Summary and add action", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AyJLH", + "name": "Ready nodes summary", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "4 / 5 ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 22, + "fontWeight": "600" + }, + { + "id": "UfqPv", + "type": "ref", + "ref": "cb4rt", + "name": "Add node", + "descendants": { + "U6q13B": { + "content": "Add node" + } + } + } + ] + }, + { + "id": "n6oP59", + "type": "ref", + "ref": "b2JF52", + "name": "Search and Filters — mobile", + "width": "fill_container", + "descendants": { + "lFVUp": { + "width": "fill_container" + }, + "SJPou": { + "enabled": false + } + } + }, + { + "id": "lPwhX", + "type": "ref", + "ref": "AT7ya", + "name": "Sort nodes", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "Sort: Node name ↑" + } + } + }, + { + "type": "frame", + "id": "lwDAR", + "name": "Partial inventory", + "width": "fill_container", + "fill": "$warning/soft", + "cornerRadius": 12, + "padding": 12, + "children": [ + { + "type": "text", + "id": "QHyKa", + "name": "Partial data message", + "fill": "$warning/soft-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "West is unavailable. Showing returned nodes from 2 clusters.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "N9jF2", + "name": "Stacked node kubelab-control", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "oVcLv", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "kubelab-control", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "b0hCk", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "e9smH", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Local", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "skDFf", + "name": "Node readiness", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e7BBT", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "IYQrV", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "SOSUF", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "MiM3F", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "24%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EH7Um", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "BYYKp", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "lSyUT", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "42%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "irSmz", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "injmY", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "ISCDR", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "q8IgD", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "18 / 110", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "BYITX", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "gxE2r", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "zYFNR", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "12d 4h", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ykmyY", + "name": "Stacked node kubelab-worker-1", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "numlc", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "kubelab-worker-1", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "VSged", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "w8Uf9", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Local", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "GlodJ", + "name": "Node readiness", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "O5J8X", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "P8HVB", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "RVbji", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wBN3n", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "56%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EBF5E", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "P0Nqw", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "acJIC", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "65%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "V67Jca", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "nQBSO", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "snNcS", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "H5I2T", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "24 / 110", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "J73VxW", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "vdw5N", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "mxgSz", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "12d 4h", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "z6YxP", + "name": "Stacked node kubelab-worker-2", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "FvI2s", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "kubelab-worker-2", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "koDwG", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "F2mEZ", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Local", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "S6orlq", + "name": "Node readiness", + "fill": "$danger/danger", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "NotReady", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qBVBr", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "U0sXp", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "mAnns", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "NiLG7", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Unknown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DW6j5", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Y4TryU", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Ui13m", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Unknown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "s66D9", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "hn73J", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "MLpBD", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "fZvBi", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Unknown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DDdw0", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "vRNYR", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "l1vfix", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Unknown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "KWWKP", + "name": "Stacked node remote-worker-1", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "NYAez", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "remote-worker-1", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Aa2C9", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "cFh1D", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remote lab", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "FBFcr", + "name": "Node readiness", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "i6nxm", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "h1yKR", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "iqdyr", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "EesMg", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "31%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eu2UG", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Sbijb", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IYKGU", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "28%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Xx7rS", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "mA2kI", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Q4gCk", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "QMjup", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "14 / 110", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "RkV4t", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "IKtq2", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "qftfY", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5d 2h", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Y0SX7d", + "name": "Stacked node remote-worker-2", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": 16, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "text", + "id": "aIPdo", + "name": "Node name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "remote-worker-2", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "obZZp", + "name": "Cluster and readiness", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "text", + "id": "hkNnH", + "name": "Cluster link", + "fill": "$accent/accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remote lab", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WRovs", + "name": "Node readiness", + "fill": "$success/success", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Ready", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "G5RNfq", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "foWuz", + "name": "CPU", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "h8MKI", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "CPU", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "uUX8i", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "18%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IPycz", + "name": "Memory", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "mC9rY", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Memory", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Devwj", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "36%", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qMNI5", + "name": "Node metrics", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "cgcAq", + "name": "Pods", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "WUg41", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Pods", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KIytM", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "12 / 110", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TcSis", + "name": "Uptime", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "GP8oU", + "name": "Metric label", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Uptime", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "t0rMRW", + "name": "Metric value", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5d 2h", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "uQw9j", + "name": "Results summary", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "5 nodes shown", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "T70dJ", + "x": 21830, + "y": 63932, + "name": "Screen/Cluster Details — Configuration — Default storage", + "context": "Canonical cluster and nodes navigation redesign. Authorized implementation.", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1060, + "fill": "$background/background", + "children": [ + { + "id": "K5BoBu", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "lZ6pP": { + "fill": "$accent/soft" + }, + "lZ6pP/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "lZ6pP/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "baGuL", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "id": "tKTDq", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Clusters › Local › Configuration" + } + } + }, + { + "type": "frame", + "id": "ghWVM", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "Qy7tO", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Local" + }, + "VbHwU": { + "content": "local · Built-in cluster" + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "e7QMO9", + "name": "Cluster detail tabs", + "width": "fill_container", + "stroke": "$separator/separator", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 0, + 0, + 12, + 0 + ], + "children": [ + { + "id": "kWnAH", + "type": "ref", + "ref": "qAFbJ", + "name": "Overview tab", + "descendants": { + "VcxMI": { + "content": "Overview" + } + } + }, + { + "id": "RqiZh", + "type": "ref", + "ref": "qAFbJ", + "name": "Nodes tab", + "descendants": { + "VcxMI": { + "content": "Nodes" + } + } + }, + { + "id": "yzR6r", + "type": "ref", + "ref": "CEWo3", + "name": "Configuration tab", + "descendants": { + "YUR8N": { + "content": "Configuration" + } + } + } + ] + }, + { + "type": "frame", + "id": "ikyuE", + "name": "Access", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "tuItf", + "name": "Access heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Access", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "CibbM", + "name": "Access description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Download a kubeconfig for this cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "eSxBK", + "type": "ref", + "ref": "i6gfu", + "name": "Download kubeconfig", + "descendants": { + "FCYQ9": { + "icon": "download" + }, + "GqVun": { + "content": "Download kubeconfig" + } + } + }, + { + "type": "text", + "id": "qFY0u", + "name": "Enrollment link", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "To join a machine, use Add node on the Nodes tab.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "slD0X", + "name": "Storage", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "KeNCX", + "name": "Storage heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Storage", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ia7Xn", + "name": "Storage description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No storage class is set. New volumes use the cluster default. This setting is read-only.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "voacz", + "name": "Storage class row", + "width": "fill_container", + "gap": 24, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "s5hyZ", + "name": "Storage class label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Game data storage class", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "xm6O0", + "name": "Storage class value", + "fill": "$foreground/foreground", + "content": "Cluster default", + "fontFamily": "$typography/font-mono", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "f00de", + "name": "Backup destinations", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "qXkuX", + "name": "Backup destinations heading", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Backup destinations", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "L6ioUD", + "name": "Backup destinations description", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Repositories used by servers running on this cluster.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "A6iyO", + "name": "Backup destination row", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "FD8Z3", + "name": "Repository details", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "xGtq2", + "name": "Repository name", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "nightly-backups", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "mmOOn", + "name": "Repository URL", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "s3:s3.example.com/gameplane", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "NWW4J", + "type": "ref", + "ref": "i6gfu", + "name": "Delete destination", + "descendants": { + "FCYQ9": { + "enabled": false + }, + "GqVun": { + "content": "Delete" + } + } + } + ] + }, + { + "id": "MDGwG", + "type": "ref", + "ref": "cb4rt", + "name": "Add backup destination", + "descendants": { + "U6q13B": { + "content": "Add destination" + } + } + } + ] + }, + { + "type": "text", + "id": "J0MVH", + "name": "Built-in cluster removal", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The built-in local cluster cannot be removed.", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "xrNf3", + "x": -1400, + "y": -516, + "name": "Gameplane/Nodes Filters Button", + "context": "Same trigger style as Backups. Badge counts applied Cluster + Status filters, never search. Hide badge at zero. Fixed-cluster detail counts Status only.", + "reusable": true, + "height": 36, + "fill": "$default/default", + "cornerRadius": 6, + "stroke": "$border/border", + "strokeWidth": 1, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "YMMQj", + "name": "Filter icon", + "width": 16, + "height": 16, + "icon": "list-filter", + "library": "lucide", + "fill": "$foreground/foreground" + }, + { + "type": "text", + "id": "UTUE9", + "name": "Filter label", + "fill": "$foreground/foreground", + "content": "Filter", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "500" + }, + { + "id": "Wfxw3", + "type": "ref", + "ref": "KmZnF", + "name": "Active filters count", + "descendants": { + "xZ9ah": { + "content": "2" + } + } + } + ] + }, + { + "type": "frame", + "id": "xjVA4", + "x": -1207, + "y": -516, + "name": "Gameplane/Nodes Filters Popover", + "context": "Backups pattern: opening copies applied Cluster and Status into drafts. Apply commits and closes; Clear resets drafts only. Closing without Apply discards drafts. Search remains outside the popover and badge. Cluster options: All clusters and authorized clusters. Status options: All statuses, Ready, Not ready, Unknown. In fixed-cluster detail hide Cluster and count Status only.", + "reusable": true, + "width": 280, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 12, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "d7pVy", + "name": "Cluster field", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Q5X01", + "name": "Cluster label", + "fill": "$muted", + "content": "Cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "XQluj", + "type": "ref", + "ref": "AT7ya", + "name": "Cluster select", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "All clusters", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "m2s7S", + "name": "Status field", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "b2uTl4", + "name": "Status label", + "fill": "$muted", + "content": "Status", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "O1IZPo", + "type": "ref", + "ref": "AT7ya", + "name": "Status select", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "All statuses", + "fill": "$foreground/foreground" + } + } + } + ] + }, + { + "type": "frame", + "id": "EHlqt", + "name": "Clear and Apply", + "width": "fill_container", + "gap": 8, + "padding": [ + 6, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Zq2ru", + "name": "btnClear", + "height": 28, + "cornerRadius": 6, + "padding": [ + 0, + 8 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DEESq", + "fill": "$foreground/foreground", + "content": "Clear", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "rKBVi", + "name": "btnApply", + "height": 28, + "fill": "$accent/accent", + "cornerRadius": 6, + "padding": [ + 0, + 8 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qH69x", + "fill": "$accent/foreground", + "content": "Apply", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "b2JF52", + "x": -847, + "y": -516, + "name": "Gameplane/Nodes Search and Filters Row", + "context": "Search matches Backups plain HeroUI Input, maximum 320px desktop, flexible on narrow screens. Filters stays at the right. Optional fixed-cluster pill is read-only and never increases the badge count. Opens Gameplane/Nodes Filters Popover.", + "reusable": true, + "width": 1124, + "gap": 12, + "alignItems": "center", + "children": [ + { + "id": "lFVUp", + "type": "ref", + "ref": "D0cDM", + "name": "Search nodes", + "width": 320, + "descendants": { + "PJERm": { + "content": "Search nodes…" + } + } + }, + { + "type": "frame", + "id": "SJPou", + "name": "Flexible space", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "LMf8L", + "x": 952, + "y": 0, + "name": "Fixed cluster scope", + "enabled": false, + "width": 180, + "height": 36, + "fill": "$surface/secondary", + "cornerRadius": 12, + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "T9hiu", + "name": "Fixed cluster icon", + "width": 16, + "height": 16, + "icon": "network", + "library": "lucide", + "fill": "$muted" + }, + { + "type": "text", + "id": "y6hK9", + "name": "Current cluster", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Cluster: Local", + "lineHeight": 1.45, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "id": "xJuXI", + "type": "ref", + "ref": "xrNf3", + "name": "Filters trigger", + "descendants": { + "Wfxw3": { + "enabled": false + } + } + } + ] } ], "themes": { diff --git a/docs/multicluster-ui.md b/docs/multicluster-ui.md index 2d1c93713..7b7402c90 100644 --- a/docs/multicluster-ui.md +++ b/docs/multicluster-ui.md @@ -3,7 +3,7 @@ One central login manages independent registered clusters. A cluster contains nodes and game servers; registering another cluster is separate from adding a node. Dashboard, Servers, Backups and Search combine authorized resources across -locations. See [Unified dashboard and server management](unified-dashboard.md) +clusters. See [Unified dashboard and server management](unified-dashboard.md) for filtering and server navigation. ## Registered clusters @@ -12,12 +12,13 @@ The **Clusters** page (`/clusters`) lists authorized registrations with their display name, stable ID, Kubernetes API health, version and last check when available. **Local** identifies the central cluster in combined installations; standalone panels have no Local entry and can start with no registrations. The -administration navigation is available to users with cluster-management or -inventory access. +directory is available to cluster managers and users who can discover a +registration through their workload, inventory or configuration permissions. -- **View servers** opens the server list filtered to that location. -- **View nodes** opens the selected cluster's inventory when the user has its - inventory permission. +- **Open cluster** opens its Overview, Nodes and Configuration tabs. +- **View servers** opens the server list filtered to that cluster. +- **View nodes** opens `/nodes?cluster=` when the user has its inventory + permission. It does not change the remembered infrastructure selection. - Connection health reports Kubernetes API connectivity. It does not establish gateway readiness or game health. @@ -28,15 +29,35 @@ Registration grants no workload access by itself. See ## Node inventory -The **Cluster** page (`/cluster`) shows the selected cluster's nodes and capacity. -Its queries retain that cluster ID, and changing the inventory selection clears -pending queries and cached results. A failed remote read displays an error; -it never substitutes local inventory. Loading, denied access and empty inventory -have separate states. +The **Nodes** page (`/nodes`) combines all authorized clusters by default. Its +cluster filter issues a narrower backend request; search, status and sortable +columns help find nodes within the returned inventory. Rows keep the pair of +cluster ID and node name as their identity. Desktop uses a table; mobile uses +stacked rows with the same fields. Ready counts, reporting clusters and weighted +CPU/memory summaries show their coverage. Missing metrics remain unknown. -Node-join tokens, kubeconfig issuance and installation storage configuration are -available only for the local cluster. Inventory selection does not change an -open server's target or the location filters on ordinary resource pages. +Unavailable or truncated clusters remain filterable and show incomplete coverage. +Changing the filter cannot substitute another cluster's data. The Nodes tab at +`/clusters/?tab=nodes` reuses the inventory with a fixed cluster. Old `/cluster` +bookmarks redirect to Nodes with their remembered selection. + +**Add node** requires an explicit target when viewing all clusters. Node-join +tokens remain local-only and require permission and installation support. +Changing targets discards pending join results and commands. + +## Cluster configuration + +`/clusters/?tab=configuration` owns kubeconfig access, read-only installation +storage, backup destinations and remote registration removal. Kubeconfig issuance +and installation storage remain local-only. Remote access and storage are managed +in that cluster's installation. Backup destination requests and drafts bind to +the explicit cluster and `gameplane-games` namespace, with that scope's destination +permissions. Removal requires confirmation and never applies to Local. + +Panel settings retain central authentication, telemetry, registries and +notifications. Old `/admin?section=backups` links lead users to the owning cluster +configuration without issuing an ambient destination request. Discovering a +registration for configuration does not grant node inventory access. ## User access diff --git a/docs/unified-dashboard.md b/docs/unified-dashboard.md index 1f9127f6a..5c85291fd 100644 --- a/docs/unified-dashboard.md +++ b/docs/unified-dashboard.md @@ -1,28 +1,28 @@ # Unified dashboard and server management The dashboard combines authorized resources across independent backend clusters, -with optional location filters. Cluster registration and node inventory are +with optional cluster filters. Cluster registration and node inventory are described in [Cluster administration](multicluster-ui.md). One central login/API presents the resources each user can access. Clusters remain independent execution and storage boundaries. Their Kubernetes APIs, operators and authenticated agent gateways continue to route operations; combining their presentation does not introduce cross-site scheduling, shared storage, game migration or a second user database. ## User experience -- **Dashboard:** combined server and player counts across authorized sites by default. An optional location/cluster filter narrows the same data. Inventory cards combine only sites where the user has inventory permission. CPU and memory ratios use summed usage and corresponding capacity; unavailable metrics never become zero. Display which authorized scopes are unavailable or truncated alongside partial totals. +- **Dashboard:** combined server and player counts across authorized sites by default. An optional cluster filter narrows the same data. Inventory cards combine only sites where the user has inventory permission. CPU and memory ratios use summed usage and corresponding capacity; unavailable metrics never become zero. Display which authorized scopes are unavailable or truncated alongside partial totals. - **Servers:** one list with existing game, namespace, status and text filters plus an optional cluster filter. Same-named servers remain distinct. Site and namespace metadata disambiguate results, while routine actions act on the selected row without a global infrastructure selection. - **Server detail:** canonical links carry the server's cluster and namespace. Console, files, players, settings, backups and related actions inherit that immutable resource target. Refreshing or opening a link in another tab does not depend on a previous localStorage selection. Legacy links without a cluster mean local; errors never select another site automatically. - **Backups:** snapshots, schedules and restores combine authorized scopes with optional filters. Detail, restore, create, edit and delete operations retain the originating resource's cluster and namespace. A server picker identifies duplicate names by full target. - **Search:** one authorized server search across sites. Results and navigation retain cluster, namespace and name; identical names cannot overwrite one another. - **Infrastructure:** cluster registrations and node inventory belong to administration. A selector can filter that administrative view. The ordinary shell no longer requires a global cluster switcher. Accounts, roles, audit and installation settings retain their central ownership. Combined panels manage the local module catalog; [standalone panels](standalone-panel.md) select a registered remote cluster for modules and sources. -- **Creation:** deployment targeting is a property of a new server, captured before creation and retained for any credential follow-up and success link. The API supplies eligible cluster/namespace placements and their templates using existing target-scoped permissions. A single eligible placement is automatic; several placements expose a location choice, initially preferring an eligible local placement and otherwise the first stable eligible choice. Changing placement revalidates the selected template and form. This UI does not introduce an automatic cross-cluster scheduler or retry creation on another site. +- **Creation:** deployment targeting is a property of a new server, captured before creation and retained for any credential follow-up and success link. The API supplies eligible cluster/namespace placements and their templates using existing target-scoped permissions. A single eligible placement is automatic; several placements expose a Cluster / namespace choice, initially preferring an eligible local placement and otherwise the first stable eligible choice. Changing placement revalidates the selected template and form. This UI does not introduce an automatic cross-cluster scheduler or retry creation on another site. Use existing page headers, cards, tables, menus, filters, alerts and responsive layouts. The single-site experience should remain familiar. A cluster filter changes the list being viewed, not an already-open server's identity or an outstanding operation. ## Filter layout -Servers keeps Location inside its existing right-hand Filter popover, alongside Game and Namespace. There is no separate location selector above the statistics. Backups uses the same popover shell on the right of its search row: Location, Server and the current tab's Phase choices are grouped together. Schedules offers Location and Server without a phase field. Server choices retain their full target and narrow to the draft location; switching location clears an incompatible server choice. +Servers keeps Cluster inside its existing right-hand Filter popover, alongside Game and Namespace. There is no separate cluster selector above the statistics. Backups uses the same popover shell on the right of its search row: Cluster, Server and the current tab's Phase choices are grouped together. Schedules offers Cluster and Server without a phase field. Server choices retain their full target and narrow to the draft cluster; switching cluster clears an incompatible server choice. -All popovers stage selections until Apply. Clear resets the draft, Apply commits it, and closing without applying discards changes. The trigger counts applied filters, including Location. Search stays visible, and the layout wraps at mobile widths. Route-driven location state remains the authority so sidebar navigation and Back/Forward restore the correct applied location. +All popovers stage selections until Apply. Clear resets the draft, Apply commits it, and closing without applying discards changes. The trigger counts applied filters, including Cluster. Search stays visible, and the layout wraps at mobile widths. Route-driven cluster state remains the authority so sidebar navigation and Back/Forward restore the correct applied cluster. The interface uses the existing Gameplane/HeroUI components and theme tokens. `design.pen` is the canonical design source; the @@ -34,7 +34,7 @@ Authenticated fleet reads return items with explicit `{cluster, namespace, name, Collection reads use bounded concurrency, timeouts and response limits. They return explicit partial/error/truncation indicators and stable identity ordering. Unauthorized registrations must not leak through counts or error messages. An authoritative empty result, a denied scope and an unavailable scope are different states. No failed remote query falls back to local data. -Filters issue a narrower backend read rather than only hiding rows from a capped combined response. Scope metadata exposes explicitly permitted scopes and scopes containing returned owned/collaborating servers, allowing stable location choices without revealing unrelated registrations. +Filters issue a narrower backend read rather than only hiding rows from a capped combined response. Scope metadata exposes explicitly permitted scopes and scopes containing returned owned/collaborating servers, allowing stable Cluster / namespace choices without revealing unrelated registrations. Existing per-target mutation and stream APIs remain in use. Every client operation captures its target before asynchronous work, including chained calls and raw file/socket paths. Query keys and invalidation include the resource's cluster and namespace; instance-specific data additionally includes UID. Route changes discard old forms, results and reconnect attempts. UID is also a cache/row identity; it does not claim a new universal server-side mutation precondition beyond existing endpoint and gateway safeguards. diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index a32980921..49ef454c2 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -1207,6 +1207,134 @@ "defaultMessage": "Language", "description": "Label beside the language select of each notification sink row on the admin Notifications card" }, + "web.cluster_configuration.access": { + "defaultMessage": "Access", + "description": "Cluster configuration access section" + }, + "web.cluster_configuration.access_description": { + "defaultMessage": "Download credentials for Kubernetes access when this cluster supports it.", + "description": "Cluster access section description" + }, + "web.cluster_configuration.access_permission": { + "defaultMessage": "Cluster management permission is required to download a kubeconfig.", + "description": "Cluster access permission hint" + }, + "web.cluster_configuration.backups_moved": { + "defaultMessage": "Backup destinations moved", + "description": "Legacy admin backup destination deep link title" + }, + "web.cluster_configuration.backups_moved_description": { + "defaultMessage": "Backup destinations belong to each cluster. Open the cluster's Configuration tab to manage them.", + "description": "Legacy backup settings guidance" + }, + "web.cluster_configuration.choose_cluster": { + "defaultMessage": "Choose a cluster", + "description": "Legacy backup settings link to cluster directory when no valid target exists" + }, + "web.cluster_configuration.destinations_denied": { + "defaultMessage": "You don't have permission to view backup destinations on this cluster.", + "description": "Cluster configuration: backup destination access denied" + }, + "web.cluster_configuration.enable_ops": { + "defaultMessage": "Enable {setting} in the Helm values to download kubeconfigs.", + "description": "Disabled local kubeconfig capability hint" + }, + "web.cluster_configuration.loading": { + "defaultMessage": "Loading cluster configuration…", + "description": "Cluster configuration loading state" + }, + "web.cluster_configuration.loading_permissions": { + "defaultMessage": "Loading permissions…", + "description": "Cluster configuration: identity loading state" + }, + "web.cluster_configuration.loading_storage": { + "defaultMessage": "Loading storage settings…", + "description": "Cluster storage loading state" + }, + "web.cluster_configuration.open_named_configuration": { + "defaultMessage": "Open {name} configuration", + "description": "Legacy backup settings link to selected cluster configuration" + }, + "web.cluster_configuration.permissions_error": { + "defaultMessage": "Couldn't load your permissions.", + "description": "Cluster configuration: identity loading failure" + }, + "web.cluster_configuration.remote_access": { + "defaultMessage": "Kubeconfig downloads and install-time storage settings are managed at this remote cluster.", + "description": "Cluster configuration: unsupported remote access and storage guidance" + }, + "web.cluster_configuration.remove_registration": { + "defaultMessage": "Remove registration", + "description": "Cluster removal section title" + }, + "web.cluster_configuration.storage_error": { + "defaultMessage": "Couldn't load storage settings.", + "description": "Cluster storage read failure" + }, + "web.cluster_detail.all_nodes": { + "defaultMessage": "View all nodes", + "description": "Link from cluster nodes to fleet nodes" + }, + "web.cluster_detail.back_to_clusters": { + "defaultMessage": "Back to clusters", + "description": "Return to cluster directory" + }, + "web.cluster_detail.configuration": { + "defaultMessage": "Configuration", + "description": "Cluster detail configuration tab" + }, + "web.cluster_detail.connectivity": { + "defaultMessage": "API connectivity", + "description": "Cluster overview connectivity section" + }, + "web.cluster_detail.connectivity_description": { + "defaultMessage": "Status reports Kubernetes API connectivity, not game or gateway health.", + "description": "Cluster health scope explanation" + }, + "web.cluster_detail.description": { + "defaultMessage": "Cluster connectivity, nodes and configuration.", + "description": "Cluster detail page description" + }, + "web.cluster_detail.fixed_cluster": { + "defaultMessage": "Cluster: {name}", + "description": "Fixed cluster scope label above embedded nodes" + }, + "web.cluster_detail.identity": { + "defaultMessage": "Cluster identity", + "description": "Cluster overview identity section" + }, + "web.cluster_detail.inventory_denied": { + "defaultMessage": "You don't have permission to view node inventory on this cluster.", + "description": "Cluster node inventory access denied" + }, + "web.cluster_detail.kubernetes_version": { + "defaultMessage": "Kubernetes version", + "description": "Cluster Kubernetes version label" + }, + "web.cluster_detail.loading": { + "defaultMessage": "Loading cluster…", + "description": "Cluster detail loading state" + }, + "web.cluster_detail.nodes": { + "defaultMessage": "Nodes", + "description": "Cluster detail nodes tab" + }, + "web.cluster_detail.overview": { + "defaultMessage": "Overview", + "description": "Cluster detail overview tab" + }, + "web.cluster_detail.registration_id": { + "defaultMessage": "Registration ID", + "description": "Cluster registration identifier label" + }, + "web.cluster_detail.tabs": { + "defaultMessage": "Cluster detail tabs", + "description": "Accessible cluster tab navigation label" + }, + "web.cluster_detail.unavailable": { + "defaultMessage": "Cluster unavailable", + "description": "Cluster detail requested registration is absent or inaccessible" + }, "web.common.close_glyph": { "defaultMessage": "×", "description": "Multiplication sign used as the close icon on dismiss buttons" @@ -1251,10 +1379,6 @@ "defaultMessage": "Central management.", "description": "Bold lead-in of the central-management note shown above page content" }, - "web.components.app_layout.cluster": { - "defaultMessage": "Cluster", - "description": "Sidebar: navigation link to the cluster page" - }, "web.components.app_layout.clusters": { "defaultMessage": "Clusters", "description": "Sidebar: navigation link to the clusters page" @@ -1271,6 +1395,10 @@ "defaultMessage": "Modules", "description": "Sidebar: navigation link to modules" }, + "web.components.app_layout.nodes": { + "defaultMessage": "Nodes", + "description": "Sidebar: navigation link to nodes across all authorized clusters" + }, "web.components.app_layout.page_not_found": { "defaultMessage": "Page not found", "description": "Breadcrumb for the 404 page" @@ -1288,8 +1416,8 @@ "description": "Sidebar: navigation link to system logs" }, "web.components.app_layout.this_catalog_account_or_installation": { - "defaultMessage": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", - "description": "App layout: note shown when central management applies; catalog, account or installation settings belong to the central installation, while server actions use each server's location" + "defaultMessage": "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", + "description": "App layout: note shown when central management applies; catalog, account or installation settings belong to the central installation, while server actions use each server's cluster" }, "web.components.app_layout.users_rbac": { "defaultMessage": "Users & RBAC", @@ -1408,8 +1536,8 @@ "description": "Backup filters: screen-reader label for the server dropdown in the filter panel; not visible on screen" }, "web.components.backups.backup_filters.location": { - "defaultMessage": "Location", - "description": "Backup filters: label above the location dropdown in the filter panel" + "defaultMessage": "Cluster", + "description": "Backup filters: label above the cluster dropdown in the filter panel" }, "web.components.backups.backup_filters.phase": { "defaultMessage": "Phase", @@ -1457,7 +1585,7 @@ }, "web.components.backups.backup_filters.server_option_label": { "defaultMessage": "{name} · {cluster} / {namespace}", - "description": "Backup filters: server option label with its location; {name} is the server name, {cluster} the cluster, {namespace} the namespace" + "description": "Backup filters: server option label with its cluster; {name} is the server name, {cluster} the cluster, {namespace} the namespace" }, "web.components.backups.backup_filters.server_options": { "defaultMessage": "Server options", @@ -1640,8 +1768,8 @@ "description": "Capture widget: button that closes the delete confirmation or the Start Capture dialog without making changes; keep it short" }, "web.components.capture_widget.capture_access_unavailable": { - "defaultMessage": "Capture access is temporarily unavailable at this location.", - "description": "Capture widget: status line when capture access is unavailable at this location" + "defaultMessage": "Capture access is temporarily unavailable at this cluster.", + "description": "Capture widget: status line when capture access is unavailable at this cluster" }, "web.components.capture_widget.capture_is_not_enabled_on_this_server": { "defaultMessage": "Capture is not enabled on this server.", @@ -1660,8 +1788,8 @@ "description": "Capture widget: section heading above the table of completed and failed captures for this server" }, "web.components.capture_widget.captures_disabled_at_location": { - "defaultMessage": "New captures are disabled at this location. Existing capture files remain available.", - "description": "Capture widget: status line when new captures are disabled at this location" + "defaultMessage": "New captures are disabled at this cluster. Existing capture files remain available.", + "description": "Capture widget: status line when new captures are disabled at this cluster" }, "web.components.capture_widget.capturing": { "defaultMessage": "Capturing…", @@ -1744,8 +1872,8 @@ "description": "Capture widget: column heading for the packet filter used by each capture in the captures table; keep it short" }, "web.components.capture_widget.gateway_update_needed": { - "defaultMessage": "This location needs a gateway update before captures can be managed here.", - "description": "Capture widget: status line when the location's gateway is too old for captures" + "defaultMessage": "This cluster needs a gateway update before captures can be managed here.", + "description": "Capture widget: status line when the cluster's gateway is too old for captures" }, "web.components.capture_widget.gb": { "defaultMessage": "GB", @@ -1988,20 +2116,20 @@ "description": "Cluster selector: typeahead text of the view-all-clusters item" }, "web.components.fleet_scope.all_locations": { - "defaultMessage": "All locations", - "description": "Fleet scope: first option in the location dropdown that clears the location filter and shows every location; keep it short" + "defaultMessage": "All clusters", + "description": "Fleet scope: first option in the cluster dropdown that clears the cluster filter and shows every cluster; keep it short" }, "web.components.fleet_scope.all_locations_text": { - "defaultMessage": "All locations", - "description": "Fleet scope: typeahead text of the all-locations option" + "defaultMessage": "All clusters", + "description": "Fleet scope: typeahead text of the all-clusters option" }, "web.components.fleet_scope.counts_cover_only_returned_resources": { "defaultMessage": "Counts cover only returned resources.", "description": "Fleet scope: second part of the partial-results warning, after the heading, saying counts include only the resources that were returned" }, "web.components.fleet_scope.filter_by_location": { - "defaultMessage": "Filter by location", - "description": "Fleet scope: screen-reader label for the location dropdown that narrows the view to one location; not visible on screen" + "defaultMessage": "Filter by cluster", + "description": "Fleet scope: screen-reader label for the cluster dropdown that narrows the view to one cluster; not visible on screen" }, "web.components.fleet_scope.label_are_partial": { "defaultMessage": "{label} are partial.", @@ -2016,8 +2144,8 @@ "description": "Fleet scope: warning shown when results are capped" }, "web.components.fleet_scope.location_options": { - "defaultMessage": "Location options", - "description": "Fleet scope: screen-reader name of the list of location choices inside the location dropdown; not visible on screen" + "defaultMessage": "Cluster options", + "description": "Fleet scope: screen-reader name of the list of cluster choices inside the cluster dropdown; not visible on screen" }, "web.components.fleet_scope.results": { "defaultMessage": "Results", @@ -3735,6 +3863,10 @@ "defaultMessage": "New", "description": "Breadcrumb: label for the create-new crumb" }, + "web.components.ui.breadcrumbs.nodes": { + "defaultMessage": "Nodes", + "description": "Breadcrumb: label for the node inventory crumb" + }, "web.components.ui.breadcrumbs.servers": { "defaultMessage": "Servers", "description": "Breadcrumb: label for the servers list crumb" @@ -4051,10 +4183,6 @@ "defaultMessage": "Authentication", "description": "Settings nav: label of the Authentication settings section" }, - "web.components.ui.settings_nav.backup_destinations": { - "defaultMessage": "Backup destinations", - "description": "Settings nav: label of the Backup destinations settings section" - }, "web.components.ui.settings_nav.general": { "defaultMessage": "General", "description": "Settings nav: label of the General settings section" @@ -4300,7 +4428,7 @@ "description": "Theme import error: the document's version field is not a number" }, "web.lib.use_server_capabilities.capture_unavailable": { - "defaultMessage": "Capture limits are unavailable for this location.", + "defaultMessage": "Capture limits are unavailable for this cluster.", "description": "Error shown when the server reports packet capture limits that are not usable" }, "web.lib.use_server_capabilities.identity_changed": { @@ -4411,6 +4539,274 @@ "defaultMessage": "Must be a whole number.", "description": "Config field validation: integer field value is not a whole number" }, + "web.nodes.all_clusters": { + "defaultMessage": "All clusters", + "description": "Nodes: option to include every authorized cluster" + }, + "web.nodes.all_statuses": { + "defaultMessage": "All statuses", + "description": "Nodes: option to include every node status" + }, + "web.nodes.ascending": { + "defaultMessage": "Ascending", + "description": "Nodes: ascending sort direction" + }, + "web.nodes.cluster": { + "defaultMessage": "Cluster", + "description": "Nodes: cluster identity column label" + }, + "web.nodes.cluster_description": { + "defaultMessage": "Node inventory for {cluster}.", + "description": "Nodes inventory description fixed to one cluster" + }, + "web.nodes.cluster_identity": { + "defaultMessage": "{name} · {id}", + "description": "Nodes: mobile row cluster identity when its display name differs from its registration ID; {name} is the display name and {id} is the registration ID" + }, + "web.nodes.cores": { + "defaultMessage": "{used} / {capacity} cores", + "description": "Nodes: measured CPU usage and total core capacity" + }, + "web.nodes.count": { + "defaultMessage": "{count, plural, one {# node} other {# nodes}}", + "description": "Nodes: count of visible returned rows" + }, + "web.nodes.coverage": { + "defaultMessage": "Node results", + "description": "Nodes: resource label in fleet coverage notice" + }, + "web.nodes.cpu": { + "defaultMessage": "CPU", + "description": "Nodes: CPU usage column and summary label" + }, + "web.nodes.descending": { + "defaultMessage": "Descending", + "description": "Nodes: descending sort direction" + }, + "web.nodes.description": { + "defaultMessage": "Monitor nodes and resource usage across your clusters.", + "description": "Nodes inventory description" + }, + "web.nodes.empty": { + "defaultMessage": "No nodes are reporting in this scope.", + "description": "Nodes: complete empty inventory" + }, + "web.nodes.enrollment.add": { + "defaultMessage": "Add node", + "description": "Nodes: opens node enrollment or creates a command for the selected local cluster" + }, + "web.nodes.enrollment.capabilities": { + "defaultMessage": "Couldn't load installation capabilities.", + "description": "Nodes: enrollment unavailable because installation capabilities failed" + }, + "web.nodes.enrollment.choose": { + "defaultMessage": "Choose a cluster before enrolling a node.", + "description": "Nodes: prompt requiring an explicit enrollment cluster" + }, + "web.nodes.enrollment.clusters": { + "defaultMessage": "Couldn't load registered clusters.", + "description": "Nodes: enrollment cluster discovery failed" + }, + "web.nodes.enrollment.copy": { + "defaultMessage": "Copy", + "description": "Nodes: copies the generated join command" + }, + "web.nodes.enrollment.copy_failed": { + "defaultMessage": "Couldn't copy the command. Select and copy it manually.", + "description": "Nodes: clipboard failure" + }, + "web.nodes.enrollment.denied": { + "defaultMessage": "You don't have permission to do that.", + "description": "Nodes: enrollment request was denied" + }, + "web.nodes.enrollment.disabled": { + "defaultMessage": "Enable clusterOps.enabled in the Helm values to mint node-join tokens.", + "description": "Nodes: explanation when cluster operations are disabled; clusterOps.enabled is a machine configuration key" + }, + "web.nodes.enrollment.dismiss": { + "defaultMessage": "Dismiss", + "description": "Nodes: closes the enrollment result or cluster chooser" + }, + "web.nodes.enrollment.empty": { + "defaultMessage": "Register a workload cluster on the Clusters page to enroll nodes.", + "description": "Nodes: no registered enrollment targets exist" + }, + "web.nodes.enrollment.failed": { + "defaultMessage": "That operation failed. Check the API logs.", + "description": "Nodes: generic enrollment failure" + }, + "web.nodes.enrollment.forbidden": { + "defaultMessage": "You don't have permission to enroll nodes.", + "description": "Nodes: enrollment permission requirement" + }, + "web.nodes.enrollment.generate": { + "defaultMessage": "Generate join command", + "description": "Nodes: creates the join command after selecting an enrollment cluster" + }, + "web.nodes.enrollment.instruction": { + "defaultMessage": "Run this on the machine you want to join (token expires {expiresAt}):", + "description": "Nodes: join command instructions and token expiration" + }, + "web.nodes.enrollment.join": { + "defaultMessage": "Join a node to {cluster}", + "description": "Nodes: title of a generated command, with explicit cluster identity" + }, + "web.nodes.enrollment.no_local": { + "defaultMessage": "No local workload cluster is available on this installation.", + "description": "Nodes: local enrollment is unavailable on a standalone panel" + }, + "web.nodes.enrollment.not_enabled": { + "defaultMessage": "Cluster operations aren't enabled on this install (set clusterOps.enabled).", + "description": "Nodes: enrollment error for a disabled cluster-operations endpoint" + }, + "web.nodes.enrollment.remote": { + "defaultMessage": "Node enrollment is managed at this remote cluster.", + "description": "Nodes: explanation for unsupported remote enrollment" + }, + "web.nodes.enrollment.target": { + "defaultMessage": "Enrollment cluster", + "description": "Nodes: accessible label for enrollment cluster selection" + }, + "web.nodes.filter": { + "defaultMessage": "Filter", + "description": "Nodes: button that opens the Cluster and Status filter panel, with a count of applied filters; search is not included in the count" + }, + "web.nodes.filter_cluster": { + "defaultMessage": "Filter by cluster", + "description": "Nodes: cluster filter accessible label" + }, + "web.nodes.filter_status": { + "defaultMessage": "Filter by status", + "description": "Nodes: status filter accessible label" + }, + "web.nodes.list": { + "defaultMessage": "Node inventory", + "description": "Nodes: accessible table and mobile list name" + }, + "web.nodes.loading": { + "defaultMessage": "Loading nodes…", + "description": "Nodes: initial inventory loading state" + }, + "web.nodes.measured": { + "defaultMessage": "Capacity-weighted usage", + "description": "Nodes: CPU and memory summary uses summed usage and capacity" + }, + "web.nodes.memory": { + "defaultMessage": "Memory", + "description": "Nodes: memory usage column and summary label" + }, + "web.nodes.memory_usage": { + "defaultMessage": "{used} / {capacity}", + "description": "Nodes: memory usage and capacity, both formatted sizes" + }, + "web.nodes.missing_inventory": { + "defaultMessage": "Node inventory was not reported.", + "description": "Nodes: a returned cluster omitted its node inventory" + }, + "web.nodes.no_data": { + "defaultMessage": "No node data is available for this scope.", + "description": "Nodes: no returned data when inventory is incomplete or failed" + }, + "web.nodes.no_match": { + "defaultMessage": "No nodes match these filters.", + "description": "Nodes: search and status filters matched no nodes" + }, + "web.nodes.node": { + "defaultMessage": "Node", + "description": "Nodes: node identity column label" + }, + "web.nodes.of_capacity": { + "defaultMessage": "of {capacity}", + "description": "Nodes: capacity shown when usage is unreported" + }, + "web.nodes.partial_count": { + "defaultMessage": "{count, plural, one {# node} other {# nodes}} · Returned nodes only", + "description": "Nodes: footer count when inventory coverage is partial; {count} is the number of matching nodes returned, not the complete fleet count" + }, + "web.nodes.percent": { + "defaultMessage": "{value}%", + "description": "Nodes: resource usage percentage" + }, + "web.nodes.pod_usage": { + "defaultMessage": "{used} / {capacity}", + "description": "Nodes: pod count and capacity; either missing value is an em dash" + }, + "web.nodes.pods": { + "defaultMessage": "Pods", + "description": "Nodes: pod usage column label" + }, + "web.nodes.ready": { + "defaultMessage": "Ready nodes", + "description": "Nodes: summary count of ready nodes" + }, + "web.nodes.ready_count": { + "defaultMessage": "{ready} / {total}", + "description": "Nodes: ready count out of visible returned nodes" + }, + "web.nodes.reporting": { + "defaultMessage": "Reporting clusters", + "description": "Nodes: number of clusters represented by visible nodes" + }, + "web.nodes.retry": { + "defaultMessage": "Retry", + "description": "Nodes: retries failed inventory loading" + }, + "web.nodes.returned": { + "defaultMessage": "Returned nodes only", + "description": "Nodes: incomplete coverage summary qualifier" + }, + "web.nodes.search": { + "defaultMessage": "Search nodes", + "description": "Nodes: accessible search field label" + }, + "web.nodes.search_placeholder": { + "defaultMessage": "Search nodes, clusters or labels…", + "description": "Nodes: search field placeholder" + }, + "web.nodes.sort": { + "defaultMessage": "Sort by {column}", + "description": "Nodes: accessible column sort button label" + }, + "web.nodes.sort_by": { + "defaultMessage": "Sort nodes by", + "description": "Nodes: accessible mobile sort column selector" + }, + "web.nodes.status": { + "defaultMessage": "Status", + "description": "Nodes: status column label" + }, + "web.nodes.status_not_ready": { + "defaultMessage": "Not ready", + "description": "Nodes: unhealthy node status" + }, + "web.nodes.status_ready": { + "defaultMessage": "Ready", + "description": "Nodes: healthy node status" + }, + "web.nodes.status_unknown": { + "defaultMessage": "Unknown", + "description": "Nodes: unreported or unrecognized status filter" + }, + "web.nodes.title": { + "defaultMessage": "Nodes", + "description": "Nodes inventory page and embedded tab heading" + }, + "web.nodes.unavailable": { + "defaultMessage": "Inventory unavailable", + "description": "Nodes: inventory cannot provide a reliable count" + }, + "web.nodes.unknown_metric": { + "defaultMessage": "Measurement unavailable", + "description": "Nodes: absent or incomplete resource usage summary" + }, + "web.nodes.uptime": { + "defaultMessage": "Uptime", + "description": "Nodes: uptime column label" + }, + "web.nodes.visible": { + "defaultMessage": "Matching nodes", + "description": "Nodes: scope of the filtered summary" + }, "web.routes.admin_logs.component_api": { "defaultMessage": "API server", "description": "Admin logs: tab label for the API server log source" @@ -4607,10 +5003,6 @@ "defaultMessage": "Backup destinations", "description": "Admin settings: title of the backup destinations section card for one cluster; keep it short" }, - "web.routes.admin_settings.backup_destinations_are_stored_on_the": { - "defaultMessage": "Backup destinations are stored on the workload cluster that runs your servers.", - "description": "Admin settings: subtitle under the select-a-cluster title saying backup destinations live on the workload cluster that runs your servers" - }, "web.routes.admin_settings.basic_identity_for_this_gameplane": { "defaultMessage": "Basic identity for this Gameplane instance.", "description": "Admin settings: subtitle under the General title describing the basic identity of this Gameplane instance" @@ -4639,10 +5031,6 @@ "defaultMessage": "Channel", "description": "Admin settings: label of the update channel row; its value is set by the container image tags" }, - "web.routes.admin_settings.choose_a_registered_cluster_above_or": { - "defaultMessage": "Choose a registered cluster above, or register one on the Clusters page.", - "description": "Admin settings: text in the select-a-cluster card asking the user to pick a cluster above or register one on the Clusters page" - }, "web.routes.admin_settings.client_id": { "defaultMessage": "Client ID", "description": "Admin settings: label of the OAuth client ID field for the provider" @@ -4675,14 +5063,6 @@ "defaultMessage": "Could not refresh configuration. Retry the refresh before saving.", "description": "Admin settings: error shown when the config could not be re-fetched before a save" }, - "web.routes.admin_settings.couldn_t_load_installation_capabilities": { - "defaultMessage": "Couldn't load installation capabilities.", - "description": "Admin settings: error message in the backup destinations area when installation details fail to load; a retry button follows it" - }, - "web.routes.admin_settings.couldn_t_load_registered_clusters": { - "defaultMessage": "Couldn't load registered clusters.", - "description": "Admin settings: error message in the backup destinations area when the list of registered clusters fails to load" - }, "web.routes.admin_settings.data_handling_statement": { "defaultMessage": "Data-handling statement", "description": "Admin settings: link to the telemetry data-handling statement, which opens in a new tab; keep it short" @@ -4959,10 +5339,6 @@ "defaultMessage": "— like", "description": "Admin settings: the words for example in the scopes hint, introducing the example scope named groups" }, - "web.routes.admin_settings.loading_clusters": { - "defaultMessage": "Loading clusters…", - "description": "Admin settings: loading text in the backup destinations area while the list of registered clusters loads" - }, "web.routes.admin_settings.loading_configuration": { "defaultMessage": "Loading configuration…", "description": "Admin settings: loading text shown in a card while the configuration is fetched" @@ -4971,10 +5347,6 @@ "defaultMessage": "Loading destinations…", "description": "Admin settings: loading text in the backup destinations card while the list is fetched" }, - "web.routes.admin_settings.loading_installation": { - "defaultMessage": "Loading installation…", - "description": "Admin settings: loading text in the backup destinations area while installation details are fetched" - }, "web.routes.admin_settings.mail_example_com": { "defaultMessage": "mail.example.com", "description": "Admin settings: grey example host name inside the SMTP host field, shown only while it is empty" @@ -5279,10 +5651,6 @@ "defaultMessage": "Scopes (optional)", "description": "Admin settings: label of the optional field for extra OAuth scopes" }, - "web.routes.admin_settings.select_a_workload_cluster": { - "defaultMessage": "Select a workload cluster", - "description": "Admin settings: title of the placeholder card shown when no workload cluster is selected for backup destinations" - }, "web.routes.admin_settings.send_basic_usage_metrics": { "defaultMessage": "Send basic usage metrics", "description": "Admin settings: label of the switch that turns on basic usage metrics" @@ -5788,8 +6156,8 @@ "description": "Backups: column heading for the size of each backup; keep it short" }, "web.routes.backups.snapshots_schedules_and_restores_across": { - "defaultMessage": "Snapshots, schedules, and restores across your authorized locations.", - "description": "Backups: subtitle under the page title saying the screen covers snapshots, schedules and restores across your authorised locations" + "defaultMessage": "Snapshots, schedules, and restores across your authorized clusters.", + "description": "Backups: subtitle under the page title saying the screen covers snapshots, schedules and restores across your authorised clusters" }, "web.routes.backups.starting": { "defaultMessage": "Starting…", @@ -5835,26 +6203,10 @@ "defaultMessage": "Type {name} ({target}) to confirm.", "description": "Backups: confirmation hint under the delete-schedule dialog; {name} is the schedule name in monospace, {target} is its cluster and namespace" }, - "web.routes.cluster.add_node": { - "defaultMessage": "Add node", - "description": "Cluster: button that creates a node-join token so a new machine can be added to the cluster; keep it short" - }, - "web.routes.cluster.choose_a_registered_cluster_to_view_its": { - "defaultMessage": "Choose a registered cluster to view its node inventory. Register workload clusters on the Clusters page.", - "description": "Cluster: hint under the empty-state heading saying a registered cluster must be picked to see its nodes and that clusters are registered on the Clusters page" - }, - "web.routes.cluster.cluster": { - "defaultMessage": "Cluster", - "description": "Cluster: page title of the node inventory view at the top of the page; keep it short" - }, "web.routes.cluster.cluster_default": { "defaultMessage": "Cluster default", "description": "Cluster: badge shown instead of a storage class name when none is set, meaning new volumes use the cluster's default; keep it short" }, - "web.routes.cluster.copy": { - "defaultMessage": "Copy", - "description": "Cluster: button that copies the join command to the clipboard; keep it short" - }, "web.routes.cluster.couldn_t_load_installation_capabilities": { "defaultMessage": "Couldn't load installation capabilities.", "description": "Cluster: error message shown when the installation capabilities cannot be fetched; a retry button appears below it" @@ -5863,78 +6215,14 @@ "defaultMessage": "Couldn't load registered clusters.", "description": "Cluster: error message shown when the list of registered clusters fails to load; a retry button appears below it" }, - "web.routes.cluster.cpu": { - "defaultMessage": "CPU", - "description": "Cluster: label for CPU on a node card, used both for the core count figure and for the CPU usage bar; CPU is a technical term, usually left untranslated" - }, - "web.routes.cluster.cpu_cores": { - "defaultMessage": "{cores} cores", - "description": "Cluster: node CPU capacity; {cores} is the core count" - }, - "web.routes.cluster.dismiss": { - "defaultMessage": "Dismiss", - "description": "Cluster: button that closes the join-a-node card; keep it short" - }, "web.routes.cluster.download_kubeconfig": { "defaultMessage": "Download kubeconfig", "description": "Cluster: button that downloads the cluster's kubeconfig file; keep it short; kubeconfig is a technical term, usually left untranslated" }, - "web.routes.cluster.enable_helm_note": { - "defaultMessage": "Enable {setting} in the Helm values to mint node-join tokens and kubeconfigs.", - "description": "Cluster: note under the action buttons when operations are disabled; {setting} is the Helm key, shown as code" - }, "web.routes.cluster.game_data_storage_class": { "defaultMessage": "Game data storage class", "description": "Cluster: label of the setting that names the storage class used for new game server data volumes; storage class is a technical term, usually left untranslated" }, - "web.routes.cluster.join_a_node": { - "defaultMessage": "Join a node", - "description": "Cluster: heading of the card that shows the command to run on a machine that should join the cluster as a node" - }, - "web.routes.cluster.loading_clusters": { - "defaultMessage": "Loading clusters…", - "description": "Cluster: loading text shown while the list of registered clusters is being fetched, before a cluster can be chosen" - }, - "web.routes.cluster.loading_installation": { - "defaultMessage": "Loading installation…", - "description": "Cluster: loading text shown in place of the node inventory while the installation capabilities are being fetched" - }, - "web.routes.cluster.loading_nodes_for": { - "defaultMessage": "Loading nodes for {clusterId}…", - "description": "Cluster: message of LoadingCard; {clusterId} is the cluster id" - }, - "web.routes.cluster.memory": { - "defaultMessage": "Memory", - "description": "Cluster: label of the memory usage bar on a node card, showing memory in use against the node's total memory" - }, - "web.routes.cluster.memory_of_capacity": { - "defaultMessage": "of {capacity}", - "description": "Cluster: node memory capacity shown when usage is unknown; {capacity} is a formatted byte size" - }, - "web.routes.cluster.memory_used_of_capacity": { - "defaultMessage": "{used} / {capacity}", - "description": "Cluster: node memory as used / capacity; both are formatted byte sizes" - }, - "web.routes.cluster.no_node_data_yet": { - "defaultMessage": "No node data yet.", - "description": "Cluster: empty-state heading shown when no node information has arrived for the cluster; the line below it says what to check" - }, - "web.routes.cluster.no_nodes_are_reporting_yet_check_the": { - "defaultMessage": "No nodes are reporting yet — check the API and operator connection.", - "description": "Cluster: hint under the empty-state heading asking the user to check the API and operator connection because no nodes report yet" - }, - "web.routes.cluster.node_inventory_for": { - "defaultMessage": "Node inventory for selected cluster: {clusterId}.", - "description": "Cluster: page subtitle shown before node data loads; {clusterId} is the cluster id" - }, - "web.routes.cluster.node_status_badge": { - "defaultMessage": "● {status}", - "description": "Cluster: node status badge; {status} is the node status, or Unknown when none is reported" - }, - "web.routes.cluster.nodes_load_failed": { - "defaultMessage": "Couldn't load node inventory for {clusterId}. Check your cluster access and connection.", - "description": "Cluster: error card message when node inventory fails to load; {clusterId} is the cluster id" - }, "web.routes.cluster.op_failed": { "defaultMessage": "That operation failed. Check the API logs.", "description": "Cluster: generic error shown when a cluster operation fails" @@ -5947,54 +6235,10 @@ "defaultMessage": "Cluster operations aren't enabled on this install (set clusterOps.enabled).", "description": "Cluster: error shown when the operator has cluster operations turned off (HTTP 501)" }, - "web.routes.cluster.ops_disabled_hint": { - "defaultMessage": "Cluster operations are disabled on this install.", - "description": "Cluster: tooltip on the node action buttons when cluster operations are off" - }, - "web.routes.cluster.pods": { - "defaultMessage": "Pods", - "description": "Cluster: label of the pod figure on a node card; the value shows pods in use out of the node's pod capacity, or only the capacity when usage is unknown" - }, - "web.routes.cluster.pods_of_capacity": { - "defaultMessage": "of {capacity}", - "description": "Cluster: node pod capacity shown when usage is unknown; {capacity} is the pod limit" - }, - "web.routes.cluster.pods_used_of_capacity": { - "defaultMessage": "{used}/{capacity}", - "description": "Cluster: node pod count as used/capacity; {used} and {capacity} are numbers" - }, - "web.routes.cluster.remote_managed_note": { - "defaultMessage": "Node enrollment and kubeconfig downloads are managed at this remote cluster.", - "description": "Cluster: note under the action buttons on a remote cluster" - }, - "web.routes.cluster.role_worker": { - "defaultMessage": "worker", - "description": "Cluster: role label shown for a node that reports no Kubernetes roles" - }, - "web.routes.cluster.run_this_on_the_machine_you_want_to": { - "defaultMessage": "Run this on the machine you want to join (token expires {expiresAt}):", - "description": "Cluster: sentence with expiresAt in p" - }, - "web.routes.cluster.select_a_workload_cluster": { - "defaultMessage": "Select a workload cluster", - "description": "Cluster: heading of the empty-state card shown when no workload cluster is selected" - }, "web.routes.cluster.set_at_install_time_via_helm_values_not": { "defaultMessage": "Set at install time via Helm values — not editable from the dashboard.", "description": "Cluster: hint under the storage heading explaining that these values come from Helm values at install time and cannot be edited in the dashboard" }, - "web.routes.cluster.status_not_ready": { - "defaultMessage": "NotReady", - "description": "Cluster: node status label for a node that is not ready" - }, - "web.routes.cluster.status_ready": { - "defaultMessage": "Ready", - "description": "Cluster: node status label for a node that is ready" - }, - "web.routes.cluster.status_unknown": { - "defaultMessage": "Unknown", - "description": "Cluster: node status shown when the node reports none" - }, "web.routes.cluster.storage": { "defaultMessage": "Storage", "description": "Cluster: section heading for the storage settings that are fixed at install time; shown only to users who can read the cluster configuration" @@ -6007,16 +6251,8 @@ "defaultMessage": "StorageClass for new GameServers' game-data volumes — set by the operator.gameDataStorage.storageClassName Helm value and applied by the operator to every new PVC; the API is passed the same value as --game-data-storage-class only to report it here.", "description": "Cluster: explanation under the game data storage class label; a Helm value sets it and it applies to new volumes; StorageClass is a technical term, usually left untranslated" }, - "web.routes.cluster.summary": { - "defaultMessage": "{name} · {version} · {ready}/{total} nodes healthy", - "description": "Cluster: page subtitle; {name} is the cluster name, {version} the Kubernetes version, {ready} and {total} the node counts" - }, - "web.routes.cluster.uptime": { - "defaultMessage": "Uptime", - "description": "Cluster: label of the uptime figure on a node card, showing how long the node has been running; keep it short" - }, "web.routes.clusters.administration_of_registered_locations": { - "defaultMessage": "Administration of registered locations, connectivity and node inventory.", + "defaultMessage": "Registered clusters, connectivity and node inventory.", "description": "Clusters: subtitle under the page title saying the page covers registered locations, their connectivity and node inventory" }, "web.routes.clusters.api": { @@ -6035,10 +6271,6 @@ "defaultMessage": "Central cluster", "description": "Clusters: note on the cluster card of the built-in local cluster that the panel itself runs in" }, - "web.routes.clusters.cluster_administration_requires": { - "defaultMessage": "Cluster administration requires inventory or cluster-management access.", - "description": "Clusters: error card shown instead of the cluster list to users who have neither cluster management nor inventory access" - }, "web.routes.clusters.clusters": { "defaultMessage": "Clusters", "description": "Clusters: page title at the top of the cluster administration page; keep it short" @@ -6067,6 +6299,14 @@ "defaultMessage": "No clusters available", "description": "Clusters: heading of the empty-state card shown when no cluster is registered or visible to the user" }, + "web.routes.clusters.open_cluster": { + "defaultMessage": "Open cluster", + "description": "Open cluster details button" + }, + "web.routes.clusters.open_cluster_named": { + "defaultMessage": "Open cluster {name}", + "description": "Accessible cluster details button label" + }, "web.routes.clusters.phase_healthy": { "defaultMessage": "Healthy", "description": "Clusters: API status label for a cluster the panel reports as healthy" @@ -6087,10 +6327,6 @@ "defaultMessage": "Register a workload cluster to deploy and manage game servers. This panel can run without a local cluster.", "description": "Clusters: empty-state hint shown to users who can manage clusters" }, - "web.routes.clusters.remove": { - "defaultMessage": "Remove", - "description": "Clusters: button on a cluster card that removes its registration; not shown for the local cluster; keep it short" - }, "web.routes.clusters.remove_cluster": { "defaultMessage": "Remove cluster", "description": "Clusters: confirm button in the remove-cluster dialog, shown in a destructive style; keep it short" @@ -6107,10 +6343,6 @@ "defaultMessage": "Removes this panel's connection to the cluster. Running workloads remain on the cluster.", "description": "Clusters: text in the remove-cluster confirmation dialog saying the panel's connection is removed while running workloads stay on the cluster" }, - "web.routes.clusters.selected": { - "defaultMessage": "Selected", - "description": "Clusters: small badge on a cluster card marking the cluster currently selected; keep it short" - }, "web.routes.clusters.view_nodes": { "defaultMessage": "View nodes", "description": "Clusters: button on a cluster card that opens the node inventory for that cluster; shown only when the user can view inventory; keep it short" @@ -6156,8 +6388,8 @@ "description": "Create server: template category chip that shows every template" }, "web.routes.create_server.changing_location_starts_a_fresh_draft": { - "defaultMessage": "Changing location starts a fresh draft. The chosen location handles all creation steps.", - "description": "Create server: hint under the location dropdown warning that switching location starts a fresh draft and the chosen location handles all steps" + "defaultMessage": "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", + "description": "Create server: hint under the cluster dropdown warning that switching cluster starts a fresh draft and the chosen cluster handles all steps" }, "web.routes.create_server.choose_a_version": { "defaultMessage": "Choose a version", @@ -6169,7 +6401,7 @@ }, "web.routes.create_server.cluster_namespace": { "defaultMessage": "{cluster} / {namespace}", - "description": "Create server: location choice label; {cluster} is the cluster name and {namespace} the namespace" + "description": "Create server: cluster choice label; {cluster} is the cluster name and {namespace} the namespace" }, "web.routes.create_server.comma_or_space_separated": { "defaultMessage": "Comma or space separated.", @@ -6284,8 +6516,8 @@ "description": "Create server: memory hint in the YAML preview panel asking the user to allow for mod or plugin overhead; the example is about 1.5 GB for 4 Minecraft Vanilla players" }, "web.routes.create_server.finding_available_locations": { - "defaultMessage": "Finding available locations…", - "description": "Create server: loading text shown while the list of server locations is being fetched" + "defaultMessage": "Finding available clusters…", + "description": "Create server: loading text shown while the list of server clusters is being fetched" }, "web.routes.create_server.frp_server_port_placeholder": { "defaultMessage": "7000", @@ -6328,12 +6560,12 @@ "description": "Create server: loader line under a version option; {loader} is the loader name" }, "web.routes.create_server.location": { - "defaultMessage": "Location", - "description": "Create server: label of the location dropdown in the wizard header, which chooses the cluster and namespace the server is created in" + "defaultMessage": "Cluster / namespace", + "description": "Create server: label of the cluster dropdown in the wizard header, which chooses the cluster and namespace the server is created in" }, "web.routes.create_server.locations_unavailable": { - "defaultMessage": "Locations unavailable", - "description": "Create server: title of the error card shown when the list of authorized server locations fails to load" + "defaultMessage": "Clusters unavailable", + "description": "Create server: title of the error card shown when the list of authorized server clusters fails to load" }, "web.routes.create_server.map_at_least_one_port_name_to_a_remote": { "defaultMessage": "Map at least one port name to a remote port (1–65535).", @@ -6392,8 +6624,8 @@ "description": "Create server: heading of the wizard dialog for creating a new game server, shown at the top left" }, "web.routes.create_server.no_available_location": { - "defaultMessage": "No available location", - "description": "Create server: title of the error card shown when no location is available to create a server in" + "defaultMessage": "No available cluster", + "description": "Create server: title of the error card shown when no cluster is available to create a server in" }, "web.routes.create_server.no_description": { "defaultMessage": "No description.", @@ -6529,7 +6761,7 @@ }, "web.routes.create_server.review_configuration": { "defaultMessage": "Configuration", - "description": "Create server: review section title for the server name, location and resources" + "description": "Create server: review section title for the server name, cluster and resources" }, "web.routes.create_server.review_cpu": { "defaultMessage": "CPU", @@ -6568,7 +6800,7 @@ "description": "Create server: review row label for the load balancer source IP allow-list" }, "web.routes.create_server.review_location": { - "defaultMessage": "Location", + "defaultMessage": "Cluster / namespace", "description": "Create server: review row label for the cluster and namespace the server is created in" }, "web.routes.create_server.review_memory": { @@ -6648,8 +6880,8 @@ "description": "Create server: field label for the port of the frp relay server" }, "web.routes.create_server.some_locations_are_unavailable": { - "defaultMessage": "Some locations are unavailable. Available locations are shown.", - "description": "Create server: status note under the location dropdown shown when some locations are unavailable and only the available ones are listed" + "defaultMessage": "Some clusters are unavailable. Available clusters are shown.", + "description": "Create server: status note under the cluster dropdown shown when some clusters are unavailable and only the available ones are listed" }, "web.routes.create_server.source_ranges_placeholder": { "defaultMessage": "203.0.113.0/24 10.0.0.0/8", @@ -6712,8 +6944,8 @@ "description": "Create server: small uppercase heading above the template-specific settings, shown only when the template has such settings" }, "web.routes.create_server.the_selected_location_is_unavailable_or": { - "defaultMessage": "The selected location is unavailable or no reachable location permits creation. Restore its access or return to the server list to choose an available location.", - "description": "Create server: explanation under the no-location title; tells the user to restore access to the location or go back to the server list" + "defaultMessage": "The selected cluster is unavailable or no reachable cluster permits creation. Restore its access or return to the server list to choose an available cluster.", + "description": "Create server: explanation under the no-cluster title; tells the user to restore access to the cluster or go back to the server list" }, "web.routes.create_server.tunnel_name_optional": { "defaultMessage": "Tunnel name (optional)", @@ -6728,8 +6960,8 @@ "description": "Create server: field label above the choice of tunnel provider, shown when the tunnel is enabled" }, "web.routes.create_server.unable_to_load_authorized_server": { - "defaultMessage": "Unable to load authorized server locations.", - "description": "Create server: message under the locations error title, saying the authorized server locations could not be loaded" + "defaultMessage": "Unable to load authorized server clusters.", + "description": "Create server: message under the clusters error title, saying the authorized server clusters could not be loaded" }, "web.routes.create_server.used_as_pod_name_and_subdomain": { "defaultMessage": "Used as pod name and subdomain. Lowercase, dash-separated.", @@ -6936,7 +7168,7 @@ "description": "Dashboard: empty Recent backups card when the backup list is complete" }, "web.routes.dashboard.no_issues_partial": { - "defaultMessage": "No issues among returned servers; some locations are unavailable.", + "defaultMessage": "No issues among returned servers; some clusters are unavailable.", "description": "Dashboard: Needs attention message when the fleet is partial and no returned server needs attention" }, "web.routes.dashboard.no_node_data": { @@ -7071,13 +7303,13 @@ "defaultMessage": "View all", "description": "Dashboard: link in the recent activity card to the full audit log, and in the recent backups card to the backups page; keep it short" }, - "web.routes.dashboard.view_clusters": { - "defaultMessage": "View clusters", - "description": "Dashboard: link at the top right of the cluster resources card that opens the clusters page; keep it short" + "web.routes.dashboard.view_nodes": { + "defaultMessage": "View nodes", + "description": "Dashboard: link from cluster resources to node inventory, retaining the current cluster filter" }, "web.routes.dashboard.your_game_servers_and_resources_across": { - "defaultMessage": "Your game servers and resources across authorized locations.", - "description": "Dashboard: subtitle under the page title saying the page shows game servers and resources in the locations the user may access" + "defaultMessage": "Your game servers and resources across authorized clusters.", + "description": "Dashboard: subtitle under the page title saying the page shows game servers and resources in the clusters the user may access" }, "web.routes.login.agpl_3_0": { "defaultMessage": "AGPL-3.0", @@ -7372,8 +7604,8 @@ "description": "Server detail: error card shown when the server's identity no longer matches the one the link was for; asks the user to reload its current identity" }, "web.routes.server_detail.this_server_could_not_be_loaded_at_its": { - "defaultMessage": "This server could not be loaded at its recorded location. Check your access and the site's connection.", - "description": "Server detail: error card shown when the server or the user's access to it cannot be loaded at its recorded location; a retry button is on the card" + "defaultMessage": "This server could not be loaded at its recorded cluster. Check your access and the site's connection.", + "description": "Server detail: error card shown when the server or the user's access to it cannot be loaded at its recorded cluster; a retry button is on the card" }, "web.routes.server_detail.up_uptime": { "defaultMessage": "up {uptime}", @@ -7444,12 +7676,12 @@ "description": "Servers: table empty state while servers load" }, "web.routes.servers.location": { - "defaultMessage": "Location", - "description": "Servers: label of the location filter in the filter panel; the choices are the clusters the user can access" + "defaultMessage": "Cluster", + "description": "Servers: label of the cluster filter in the filter panel; the choices are the clusters the user can access" }, "web.routes.servers.manage_your_game_servers_across_all": { - "defaultMessage": "Manage your game servers across all authorized locations.", - "description": "Servers: subtitle under the page title saying the list covers game servers in all locations the user may access" + "defaultMessage": "Manage your game servers across all authorized clusters.", + "description": "Servers: subtitle under the page title saying the list covers game servers in all clusters the user may access" }, "web.routes.servers.memory": { "defaultMessage": "Memory", diff --git a/i18n/locales/fr/app.json b/i18n/locales/fr/app.json index ea81b4460..92e7d47c7 100644 --- a/i18n/locales/fr/app.json +++ b/i18n/locales/fr/app.json @@ -1083,10 +1083,6 @@ "defaultMessage": "Gestion centralisée.", "description": "Bold lead-in of the central-management note shown above page content" }, - "web.components.app_layout.cluster": { - "defaultMessage": "Cluster", - "description": "Sidebar: navigation link to the cluster page" - }, "web.components.app_layout.clusters": { "defaultMessage": "Clusters", "description": "Sidebar: navigation link to the clusters page" @@ -1119,10 +1115,6 @@ "defaultMessage": "Journaux système", "description": "Sidebar: navigation link to system logs" }, - "web.components.app_layout.this_catalog_account_or_installation": { - "defaultMessage": "Ce catalogue, ce compte ou ce paramètre d’installation appartient à l’installation Gameplane centrale. Les actions sur les serveurs utilisent l’emplacement de chaque serveur.", - "description": "App layout: note shown when central management applies; catalog, account or installation settings belong to the central installation, while server actions use each server's location" - }, "web.components.app_layout.users_rbac": { "defaultMessage": "Utilisateurs et RBAC", "description": "Sidebar: navigation link to users and roles" @@ -1199,10 +1191,6 @@ "defaultMessage": "Filtrer par serveur", "description": "Backup filters: screen-reader label for the server dropdown in the filter panel; not visible on screen" }, - "web.components.backups.backup_filters.location": { - "defaultMessage": "Emplacement", - "description": "Backup filters: label above the location dropdown in the filter panel" - }, "web.components.backups.backup_filters.phase": { "defaultMessage": "Phase", "description": "Backup filters: label above the phase dropdown in the filter panel; the dropdown appears only when phases exist" @@ -1431,10 +1419,6 @@ "defaultMessage": "Annuler", "description": "Capture widget: button that closes the delete confirmation or the Start Capture dialog without making changes; keep it short" }, - "web.components.capture_widget.capture_access_unavailable": { - "defaultMessage": "L'accès aux captures est temporairement indisponible à cet emplacement.", - "description": "Capture widget: status line when capture access is unavailable at this location" - }, "web.components.capture_widget.capture_is_not_enabled_on_this_server": { "defaultMessage": "La capture n'est pas activée sur ce serveur.", "description": "Capture widget: heading in the empty-state card shown when packet capture is turned off for this server" @@ -1451,10 +1435,6 @@ "defaultMessage": "Captures", "description": "Capture widget: section heading above the table of completed and failed captures for this server" }, - "web.components.capture_widget.captures_disabled_at_location": { - "defaultMessage": "Les nouvelles captures sont désactivées à cet emplacement. Les fichiers de capture existants restent disponibles.", - "description": "Capture widget: status line when new captures are disabled at this location" - }, "web.components.capture_widget.capturing": { "defaultMessage": "Capture en cours…", "description": "Capture widget: status while a capture is running" @@ -1535,10 +1515,6 @@ "defaultMessage": "Filtre", "description": "Capture widget: column heading for the packet filter used by each capture in the captures table; keep it short" }, - "web.components.capture_widget.gateway_update_needed": { - "defaultMessage": "Cet emplacement nécessite une mise à jour de la passerelle avant que les captures puissent y être gérées.", - "description": "Capture widget: status line when the location's gateway is too old for captures" - }, "web.components.capture_widget.gb": { "defaultMessage": "GB", "description": "Capture widget: option in the size unit dropdown meaning gigabytes; keep it short" @@ -1779,22 +1755,10 @@ "defaultMessage": "Voir tous les clusters", "description": "Cluster selector: typeahead text of the view-all-clusters item" }, - "web.components.fleet_scope.all_locations": { - "defaultMessage": "Tous les emplacements", - "description": "Fleet scope: first option in the location dropdown that clears the location filter and shows every location; keep it short" - }, - "web.components.fleet_scope.all_locations_text": { - "defaultMessage": "Tous les emplacements", - "description": "Fleet scope: typeahead text of the all-locations option" - }, "web.components.fleet_scope.counts_cover_only_returned_resources": { "defaultMessage": "Les totaux ne portent que sur les ressources renvoyées.", "description": "Fleet scope: second part of the partial-results warning, after the heading, saying counts include only the resources that were returned" }, - "web.components.fleet_scope.filter_by_location": { - "defaultMessage": "Filtrer par emplacement", - "description": "Fleet scope: screen-reader label for the location dropdown that narrows the view to one location; not visible on screen" - }, "web.components.fleet_scope.label_are_partial": { "defaultMessage": "{label} sont partiels.", "description": "Fleet scope: sentence with label in strong" @@ -1807,10 +1771,6 @@ "defaultMessage": "Limite de résultats atteinte.", "description": "Fleet scope: warning shown when results are capped" }, - "web.components.fleet_scope.location_options": { - "defaultMessage": "Options d'emplacement", - "description": "Fleet scope: screen-reader name of the list of location choices inside the location dropdown; not visible on screen" - }, "web.components.fleet_scope.results": { "defaultMessage": "Résultats", "description": "Fleet scope: default noun for the returned resources in the coverage notice, used when no label is passed" @@ -3843,10 +3803,6 @@ "defaultMessage": "Authentification", "description": "Settings nav: label of the Authentication settings section" }, - "web.components.ui.settings_nav.backup_destinations": { - "defaultMessage": "Destinations de sauvegarde", - "description": "Settings nav: label of the Backup destinations settings section" - }, "web.components.ui.settings_nav.general": { "defaultMessage": "Général", "description": "Settings nav: label of the General settings section" @@ -4091,10 +4047,6 @@ "defaultMessage": "export de thème invalide : version doit être un nombre", "description": "Theme import error: the document's version field is not a number" }, - "web.lib.use_server_capabilities.capture_unavailable": { - "defaultMessage": "Les limites de capture ne sont pas disponibles pour cet emplacement.", - "description": "Error shown when the server reports packet capture limits that are not usable" - }, "web.lib.use_server_capabilities.identity_changed": { "defaultMessage": "L'identité du serveur a changé. Rechargez ce serveur avant de continuer.", "description": "Error shown when a server's capabilities response no longer matches the server being viewed" @@ -4399,10 +4351,6 @@ "defaultMessage": "Destinations de sauvegarde", "description": "Admin settings: title of the backup destinations section card for one cluster; keep it short" }, - "web.routes.admin_settings.backup_destinations_are_stored_on_the": { - "defaultMessage": "Les destinations de sauvegarde sont stockées sur le cluster de charge de travail qui exécute vos serveurs.", - "description": "Admin settings: subtitle under the select-a-cluster title saying backup destinations live on the workload cluster that runs your servers" - }, "web.routes.admin_settings.basic_identity_for_this_gameplane": { "defaultMessage": "Identité de base de cette instance Gameplane.", "description": "Admin settings: subtitle under the General title describing the basic identity of this Gameplane instance" @@ -4431,10 +4379,6 @@ "defaultMessage": "Canal", "description": "Admin settings: label of the update channel row; its value is set by the container image tags" }, - "web.routes.admin_settings.choose_a_registered_cluster_above_or": { - "defaultMessage": "Choisissez un cluster enregistré ci-dessus, ou enregistrez-en un sur la page Clusters.", - "description": "Admin settings: text in the select-a-cluster card asking the user to pick a cluster above or register one on the Clusters page" - }, "web.routes.admin_settings.client_id": { "defaultMessage": "ID client", "description": "Admin settings: label of the OAuth client ID field for the provider" @@ -4467,14 +4411,6 @@ "defaultMessage": "Impossible d'actualiser la configuration. Réessayez l'actualisation avant d'enregistrer.", "description": "Admin settings: error shown when the config could not be re-fetched before a save" }, - "web.routes.admin_settings.couldn_t_load_installation_capabilities": { - "defaultMessage": "Impossible de charger les capacités de l'installation.", - "description": "Admin settings: error message in the backup destinations area when installation details fail to load; a retry button follows it" - }, - "web.routes.admin_settings.couldn_t_load_registered_clusters": { - "defaultMessage": "Impossible de charger les clusters enregistrés.", - "description": "Admin settings: error message in the backup destinations area when the list of registered clusters fails to load" - }, "web.routes.admin_settings.data_handling_statement": { "defaultMessage": "Déclaration de traitement des données", "description": "Admin settings: link to the telemetry data-handling statement, which opens in a new tab; keep it short" @@ -4751,10 +4687,6 @@ "defaultMessage": "— par exemple", "description": "Admin settings: the words for example in the scopes hint, introducing the example scope named groups" }, - "web.routes.admin_settings.loading_clusters": { - "defaultMessage": "Chargement des clusters…", - "description": "Admin settings: loading text in the backup destinations area while the list of registered clusters loads" - }, "web.routes.admin_settings.loading_configuration": { "defaultMessage": "Chargement de la configuration…", "description": "Admin settings: loading text shown in a card while the configuration is fetched" @@ -4763,10 +4695,6 @@ "defaultMessage": "Chargement des destinations…", "description": "Admin settings: loading text in the backup destinations card while the list is fetched" }, - "web.routes.admin_settings.loading_installation": { - "defaultMessage": "Chargement de l'installation…", - "description": "Admin settings: loading text in the backup destinations area while installation details are fetched" - }, "web.routes.admin_settings.mail_example_com": { "defaultMessage": "mail.example.com", "description": "Admin settings: grey example host name inside the SMTP host field, shown only while it is empty" @@ -5071,10 +4999,6 @@ "defaultMessage": "Étendues (facultatif)", "description": "Admin settings: label of the optional field for extra OAuth scopes" }, - "web.routes.admin_settings.select_a_workload_cluster": { - "defaultMessage": "Sélectionnez un cluster de charge de travail", - "description": "Admin settings: title of the placeholder card shown when no workload cluster is selected for backup destinations" - }, "web.routes.admin_settings.send_basic_usage_metrics": { "defaultMessage": "Envoyer les métriques d'utilisation de base", "description": "Admin settings: label of the switch that turns on basic usage metrics" @@ -5579,10 +5503,6 @@ "defaultMessage": "Taille", "description": "Backups: column heading for the size of each backup; keep it short" }, - "web.routes.backups.snapshots_schedules_and_restores_across": { - "defaultMessage": "Instantanés, planifications et restaurations dans vos emplacements autorisés.", - "description": "Backups: subtitle under the page title saying the screen covers snapshots, schedules and restores across your authorised locations" - }, "web.routes.backups.starting": { "defaultMessage": "Démarrage…", "description": "Backups: label of the Run snapshot button while the request is in flight" @@ -5627,26 +5547,10 @@ "defaultMessage": "Saisissez {name} ({target}) pour confirmer.", "description": "Backups: confirmation hint under the delete-schedule dialog; {name} is the schedule name in monospace, {target} is its cluster and namespace" }, - "web.routes.cluster.add_node": { - "defaultMessage": "Ajouter un nœud", - "description": "Cluster: button that creates a node-join token so a new machine can be added to the cluster; keep it short" - }, - "web.routes.cluster.choose_a_registered_cluster_to_view_its": { - "defaultMessage": "Choisissez un cluster enregistré pour afficher son inventaire de nœuds. Enregistrez les clusters de charge de travail sur la page Clusters.", - "description": "Cluster: hint under the empty-state heading saying a registered cluster must be picked to see its nodes and that clusters are registered on the Clusters page" - }, - "web.routes.cluster.cluster": { - "defaultMessage": "Cluster", - "description": "Cluster: page title of the node inventory view at the top of the page; keep it short" - }, "web.routes.cluster.cluster_default": { "defaultMessage": "Par défaut du cluster", "description": "Cluster: badge shown instead of a storage class name when none is set, meaning new volumes use the cluster's default; keep it short" }, - "web.routes.cluster.copy": { - "defaultMessage": "Copier", - "description": "Cluster: button that copies the join command to the clipboard; keep it short" - }, "web.routes.cluster.couldn_t_load_installation_capabilities": { "defaultMessage": "Impossible de charger les capacités d'installation.", "description": "Cluster: error message shown when the installation capabilities cannot be fetched; a retry button appears below it" @@ -5655,78 +5559,14 @@ "defaultMessage": "Impossible de charger les clusters enregistrés.", "description": "Cluster: error message shown when the list of registered clusters fails to load; a retry button appears below it" }, - "web.routes.cluster.cpu": { - "defaultMessage": "CPU", - "description": "Cluster: label for CPU on a node card, used both for the core count figure and for the CPU usage bar; CPU is a technical term, usually left untranslated" - }, - "web.routes.cluster.cpu_cores": { - "defaultMessage": "{cores} cœurs", - "description": "Cluster: node CPU capacity; {cores} is the core count" - }, - "web.routes.cluster.dismiss": { - "defaultMessage": "Ignorer", - "description": "Cluster: button that closes the join-a-node card; keep it short" - }, "web.routes.cluster.download_kubeconfig": { "defaultMessage": "Télécharger le kubeconfig", "description": "Cluster: button that downloads the cluster's kubeconfig file; keep it short; kubeconfig is a technical term, usually left untranslated" }, - "web.routes.cluster.enable_helm_note": { - "defaultMessage": "Activez {setting} dans les valeurs Helm pour générer des jetons d'adhésion de nœud et des kubeconfigs.", - "description": "Cluster: note under the action buttons when operations are disabled; {setting} is the Helm key, shown as code" - }, "web.routes.cluster.game_data_storage_class": { "defaultMessage": "Classe de stockage des données de jeu", "description": "Cluster: label of the setting that names the storage class used for new game server data volumes; storage class is a technical term, usually left untranslated" }, - "web.routes.cluster.join_a_node": { - "defaultMessage": "Rejoindre un nœud", - "description": "Cluster: heading of the card that shows the command to run on a machine that should join the cluster as a node" - }, - "web.routes.cluster.loading_clusters": { - "defaultMessage": "Chargement des clusters…", - "description": "Cluster: loading text shown while the list of registered clusters is being fetched, before a cluster can be chosen" - }, - "web.routes.cluster.loading_installation": { - "defaultMessage": "Chargement de l'installation…", - "description": "Cluster: loading text shown in place of the node inventory while the installation capabilities are being fetched" - }, - "web.routes.cluster.loading_nodes_for": { - "defaultMessage": "Chargement des nœuds pour {clusterId}…", - "description": "Cluster: message of LoadingCard; {clusterId} is the cluster id" - }, - "web.routes.cluster.memory": { - "defaultMessage": "Mémoire", - "description": "Cluster: label of the memory usage bar on a node card, showing memory in use against the node's total memory" - }, - "web.routes.cluster.memory_of_capacity": { - "defaultMessage": "sur {capacity}", - "description": "Cluster: node memory capacity shown when usage is unknown; {capacity} is a formatted byte size" - }, - "web.routes.cluster.memory_used_of_capacity": { - "defaultMessage": "{used} / {capacity}", - "description": "Cluster: node memory as used / capacity; both are formatted byte sizes" - }, - "web.routes.cluster.no_node_data_yet": { - "defaultMessage": "Aucune donnée de nœud pour l'instant.", - "description": "Cluster: empty-state heading shown when no node information has arrived for the cluster; the line below it says what to check" - }, - "web.routes.cluster.no_nodes_are_reporting_yet_check_the": { - "defaultMessage": "Aucun nœud ne remonte d'information pour l'instant. Vérifiez la connexion à l'API et à l'opérateur.", - "description": "Cluster: hint under the empty-state heading asking the user to check the API and operator connection because no nodes report yet" - }, - "web.routes.cluster.node_inventory_for": { - "defaultMessage": "Inventaire des nœuds du cluster sélectionné : {clusterId}.", - "description": "Cluster: page subtitle shown before node data loads; {clusterId} is the cluster id" - }, - "web.routes.cluster.node_status_badge": { - "defaultMessage": "● {status}", - "description": "Cluster: node status badge; {status} is the node status, or Unknown when none is reported" - }, - "web.routes.cluster.nodes_load_failed": { - "defaultMessage": "Impossible de charger l'inventaire des nœuds pour {clusterId}. Vérifiez votre accès au cluster et votre connexion.", - "description": "Cluster: error card message when node inventory fails to load; {clusterId} is the cluster id" - }, "web.routes.cluster.op_failed": { "defaultMessage": "Cette opération a échoué. Consultez les journaux de l'API.", "description": "Cluster: generic error shown when a cluster operation fails" @@ -5739,54 +5579,10 @@ "defaultMessage": "Les opérations sur les clusters ne sont pas activées sur cette installation (définissez clusterOps.enabled).", "description": "Cluster: error shown when the operator has cluster operations turned off (HTTP 501)" }, - "web.routes.cluster.ops_disabled_hint": { - "defaultMessage": "Les opérations sur les clusters sont désactivées sur cette installation.", - "description": "Cluster: tooltip on the node action buttons when cluster operations are off" - }, - "web.routes.cluster.pods": { - "defaultMessage": "Pods", - "description": "Cluster: label of the pod figure on a node card; the value shows pods in use out of the node's pod capacity, or only the capacity when usage is unknown" - }, - "web.routes.cluster.pods_of_capacity": { - "defaultMessage": "sur {capacity}", - "description": "Cluster: node pod capacity shown when usage is unknown; {capacity} is the pod limit" - }, - "web.routes.cluster.pods_used_of_capacity": { - "defaultMessage": "{used}/{capacity}", - "description": "Cluster: node pod count as used/capacity; {used} and {capacity} are numbers" - }, - "web.routes.cluster.remote_managed_note": { - "defaultMessage": "L'ajout de nœuds et le téléchargement des kubeconfigs sont gérés sur ce cluster distant.", - "description": "Cluster: note under the action buttons on a remote cluster" - }, - "web.routes.cluster.role_worker": { - "defaultMessage": "worker", - "description": "Cluster: role label shown for a node that reports no Kubernetes roles" - }, - "web.routes.cluster.run_this_on_the_machine_you_want_to": { - "defaultMessage": "Exécutez ceci sur la machine à rejoindre (jeton valable jusqu'à {expiresAt}) :", - "description": "Cluster: sentence with expiresAt in p" - }, - "web.routes.cluster.select_a_workload_cluster": { - "defaultMessage": "Sélectionner un cluster de charge de travail", - "description": "Cluster: heading of the empty-state card shown when no workload cluster is selected" - }, "web.routes.cluster.set_at_install_time_via_helm_values_not": { "defaultMessage": "Défini à l'installation via les valeurs Helm, non modifiable depuis le tableau de bord.", "description": "Cluster: hint under the storage heading explaining that these values come from Helm values at install time and cannot be edited in the dashboard" }, - "web.routes.cluster.status_not_ready": { - "defaultMessage": "Non prêt", - "description": "Cluster: node status label for a node that is not ready" - }, - "web.routes.cluster.status_ready": { - "defaultMessage": "Prêt", - "description": "Cluster: node status label for a node that is ready" - }, - "web.routes.cluster.status_unknown": { - "defaultMessage": "Inconnu", - "description": "Cluster: node status shown when the node reports none" - }, "web.routes.cluster.storage": { "defaultMessage": "Stockage", "description": "Cluster: section heading for the storage settings that are fixed at install time; shown only to users who can read the cluster configuration" @@ -5799,18 +5595,6 @@ "defaultMessage": "StorageClass pour les volumes de données de jeu des nouveaux GameServers : définie par la valeur Helm operator.gameDataStorage.storageClassName et appliquée par l'opérateur à chaque nouveau PVC ; l'API reçoit la même valeur via --game-data-storage-class uniquement pour l'afficher ici.", "description": "Cluster: explanation under the game data storage class label; a Helm value sets it and it applies to new volumes; StorageClass is a technical term, usually left untranslated" }, - "web.routes.cluster.summary": { - "defaultMessage": "{name} · {version} · {ready}/{total} nœuds en bon état", - "description": "Cluster: page subtitle; {name} is the cluster name, {version} the Kubernetes version, {ready} and {total} the node counts" - }, - "web.routes.cluster.uptime": { - "defaultMessage": "Temps de fonctionnement", - "description": "Cluster: label of the uptime figure on a node card, showing how long the node has been running; keep it short" - }, - "web.routes.clusters.administration_of_registered_locations": { - "defaultMessage": "Administration des emplacements enregistrés, de leur connectivité et de l'inventaire des nœuds.", - "description": "Clusters: subtitle under the page title saying the page covers registered locations, their connectivity and node inventory" - }, "web.routes.clusters.api": { "defaultMessage": "API :", "description": "Clusters: label before the API health value on a cluster card, such as Healthy or Unhealthy; the value is colored by its state; keep it short" @@ -5827,10 +5611,6 @@ "defaultMessage": "Cluster central", "description": "Clusters: note on the cluster card of the built-in local cluster that the panel itself runs in" }, - "web.routes.clusters.cluster_administration_requires": { - "defaultMessage": "L'administration des clusters requiert un accès à l'inventaire ou à la gestion des clusters.", - "description": "Clusters: error card shown instead of the cluster list to users who have neither cluster management nor inventory access" - }, "web.routes.clusters.clusters": { "defaultMessage": "Clusters", "description": "Clusters: page title at the top of the cluster administration page; keep it short" @@ -5879,10 +5659,6 @@ "defaultMessage": "Enregistrez un cluster de charge de travail pour déployer et gérer des serveurs de jeu. Ce panneau peut fonctionner sans cluster local.", "description": "Clusters: empty-state hint shown to users who can manage clusters" }, - "web.routes.clusters.remove": { - "defaultMessage": "Supprimer", - "description": "Clusters: button on a cluster card that removes its registration; not shown for the local cluster; keep it short" - }, "web.routes.clusters.remove_cluster": { "defaultMessage": "Supprimer le cluster", "description": "Clusters: confirm button in the remove-cluster dialog, shown in a destructive style; keep it short" @@ -5899,10 +5675,6 @@ "defaultMessage": "Supprime la connexion de ce panneau au cluster. Les charges de travail en cours restent sur le cluster.", "description": "Clusters: text in the remove-cluster confirmation dialog saying the panel's connection is removed while running workloads stay on the cluster" }, - "web.routes.clusters.selected": { - "defaultMessage": "Sélectionné", - "description": "Clusters: small badge on a cluster card marking the cluster currently selected; keep it short" - }, "web.routes.clusters.view_nodes": { "defaultMessage": "Voir les nœuds", "description": "Clusters: button on a cluster card that opens the node inventory for that cluster; shown only when the user can view inventory; keep it short" @@ -5947,10 +5719,6 @@ "defaultMessage": "Tous", "description": "Create server: template category chip that shows every template" }, - "web.routes.create_server.changing_location_starts_a_fresh_draft": { - "defaultMessage": "Changer d'emplacement crée un nouveau brouillon. L'emplacement choisi gère toutes les étapes de création.", - "description": "Create server: hint under the location dropdown warning that switching location starts a fresh draft and the chosen location handles all steps" - }, "web.routes.create_server.choose_a_version": { "defaultMessage": "Choisissez une version", "description": "Create server: heading of the version step, shown above the list of versions and loaders to pick from" @@ -6075,10 +5843,6 @@ "defaultMessage": "Prévoyez la surcharge des mods et plugins ; Minecraft Vanilla utilise environ 1,5 GB pour 4 joueurs.", "description": "Create server: memory hint in the YAML preview panel asking the user to allow for mod or plugin overhead; the example is about 1.5 GB for 4 Minecraft Vanilla players" }, - "web.routes.create_server.finding_available_locations": { - "defaultMessage": "Recherche des emplacements disponibles…", - "description": "Create server: loading text shown while the list of server locations is being fetched" - }, "web.routes.create_server.frp_server_port_placeholder": { "defaultMessage": "7000", "description": "Create server: placeholder of the frp server port input, an example port number" @@ -6119,14 +5883,6 @@ "defaultMessage": "Loader : {loader}", "description": "Create server: loader line under a version option; {loader} is the loader name" }, - "web.routes.create_server.location": { - "defaultMessage": "Emplacement", - "description": "Create server: label of the location dropdown in the wizard header, which chooses the cluster and namespace the server is created in" - }, - "web.routes.create_server.locations_unavailable": { - "defaultMessage": "Emplacements indisponibles", - "description": "Create server: title of the error card shown when the list of authorized server locations fails to load" - }, "web.routes.create_server.map_at_least_one_port_name_to_a_remote": { "defaultMessage": "Associez au moins un nom de port à un port distant (1–65535).", "description": "Create server: hint under the port mappings editor saying at least one port name must map to a remote port from 1 to 65535" @@ -6183,10 +5939,6 @@ "defaultMessage": "Nouveau serveur de jeu", "description": "Create server: heading of the wizard dialog for creating a new game server, shown at the top left" }, - "web.routes.create_server.no_available_location": { - "defaultMessage": "Aucun emplacement disponible", - "description": "Create server: title of the error card shown when no location is available to create a server in" - }, "web.routes.create_server.no_description": { "defaultMessage": "Aucune description.", "description": "Create server: shown under a template that has no description" @@ -6359,10 +6111,6 @@ "defaultMessage": "Liste d'autorisation IP", "description": "Create server: review row label for the load balancer source IP allow-list" }, - "web.routes.create_server.review_location": { - "defaultMessage": "Emplacement", - "description": "Create server: review row label for the cluster and namespace the server is created in" - }, "web.routes.create_server.review_memory": { "defaultMessage": "Mémoire", "description": "Create server: review row label for the memory limit" @@ -6439,10 +6187,6 @@ "defaultMessage": "Port du serveur", "description": "Create server: field label for the port of the frp relay server" }, - "web.routes.create_server.some_locations_are_unavailable": { - "defaultMessage": "Certains emplacements sont indisponibles. Seuls les emplacements disponibles sont affichés.", - "description": "Create server: status note under the location dropdown shown when some locations are unavailable and only the available ones are listed" - }, "web.routes.create_server.source_ranges_placeholder": { "defaultMessage": "203.0.113.0/24 10.0.0.0/8", "description": "Create server: placeholder of the IP allow-list textarea, example CIDR ranges one per line" @@ -6503,10 +6247,6 @@ "defaultMessage": "Configuration du modèle", "description": "Create server: small uppercase heading above the template-specific settings, shown only when the template has such settings" }, - "web.routes.create_server.the_selected_location_is_unavailable_or": { - "defaultMessage": "L'emplacement sélectionné est indisponible, ou aucun emplacement accessible n'autorise la création. Rétablissez son accès ou revenez à la liste des serveurs pour choisir un emplacement disponible.", - "description": "Create server: explanation under the no-location title; tells the user to restore access to the location or go back to the server list" - }, "web.routes.create_server.tunnel_name_optional": { "defaultMessage": "Nom du tunnel (facultatif)", "description": "Create server: field label for the optional playit tunnel name; playit is a technical term, usually left untranslated" @@ -6519,10 +6259,6 @@ "defaultMessage": "Fournisseur de tunnel", "description": "Create server: field label above the choice of tunnel provider, shown when the tunnel is enabled" }, - "web.routes.create_server.unable_to_load_authorized_server": { - "defaultMessage": "Impossible de charger les emplacements de serveur autorisés.", - "description": "Create server: message under the locations error title, saying the authorized server locations could not be loaded" - }, "web.routes.create_server.used_as_pod_name_and_subdomain": { "defaultMessage": "Utilisé comme nom de pod et sous-domaine. En minuscules, séparés par des tirets.", "description": "Create server: hint under the server name field saying the name becomes the pod name and subdomain and should be lowercase with dashes" @@ -6727,10 +6463,6 @@ "defaultMessage": "Aucun backup pour le moment.", "description": "Dashboard: empty Recent backups card when the backup list is complete" }, - "web.routes.dashboard.no_issues_partial": { - "defaultMessage": "Aucun problème parmi les serveurs renvoyés ; certains emplacements sont indisponibles.", - "description": "Dashboard: Needs attention message when the fleet is partial and no returned server needs attention" - }, "web.routes.dashboard.no_node_data": { "defaultMessage": "aucune donnée de nœud", "description": "Dashboard: sub-label on the Nodes ready card when no nodes are reported" @@ -6863,14 +6595,6 @@ "defaultMessage": "Tout voir", "description": "Dashboard: link in the recent activity card to the full audit log, and in the recent backups card to the backups page; keep it short" }, - "web.routes.dashboard.view_clusters": { - "defaultMessage": "Voir les clusters", - "description": "Dashboard: link at the top right of the cluster resources card that opens the clusters page; keep it short" - }, - "web.routes.dashboard.your_game_servers_and_resources_across": { - "defaultMessage": "Vos serveurs de jeu et ressources dans les emplacements autorisés.", - "description": "Dashboard: subtitle under the page title saying the page shows game servers and resources in the locations the user may access" - }, "web.routes.login.agpl_3_0": { "defaultMessage": "AGPL-3.0", "description": "Login: small badge above the side panel headline showing the license name; AGPL-3.0 is a technical term, usually left untranslated; keep it short" @@ -7163,10 +6887,6 @@ "defaultMessage": "Ce serveur a changé pendant son chargement. Rechargez son identité actuelle avant de continuer.", "description": "Server detail: error card shown when the server's identity no longer matches the one the link was for; asks the user to reload its current identity" }, - "web.routes.server_detail.this_server_could_not_be_loaded_at_its": { - "defaultMessage": "Ce serveur n'a pas pu être chargé à son emplacement enregistré. Vérifiez vos accès et la connexion du site.", - "description": "Server detail: error card shown when the server or the user's access to it cannot be loaded at its recorded location; a retry button is on the card" - }, "web.routes.server_detail.up_uptime": { "defaultMessage": "actif depuis {uptime}", "description": "Server detail: header line with uptime; {uptime} is the formatted uptime" @@ -7235,14 +6955,6 @@ "defaultMessage": "Chargement…", "description": "Servers: table empty state while servers load" }, - "web.routes.servers.location": { - "defaultMessage": "Emplacement", - "description": "Servers: label of the location filter in the filter panel; the choices are the clusters the user can access" - }, - "web.routes.servers.manage_your_game_servers_across_all": { - "defaultMessage": "Gérez vos serveurs de jeu dans tous les emplacements autorisés.", - "description": "Servers: subtitle under the page title saying the list covers game servers in all locations the user may access" - }, "web.routes.servers.memory": { "defaultMessage": "Mémoire", "description": "Servers: column heading for memory use per server; keep it short" diff --git a/specs/031-cluster-nodes-navigation/cluster-report.md b/specs/031-cluster-nodes-navigation/cluster-report.md new file mode 100644 index 000000000..eb309d20c --- /dev/null +++ b/specs/031-cluster-nodes-navigation/cluster-report.md @@ -0,0 +1,16 @@ +# Cluster details implementation report + +Implemented Task 3 against the approved Overview, Nodes and Configuration Pencil baseline. + +- Added `/clusters/$clusterId` page adapter with URL-owned tabs and authoritative registration resolution. Standalone rejects nonexistent local targets. Nodes mounts the shared `NodeInventory` only for `canViewInventory === true`; no remembered cluster mutation occurs. +- Added cluster Overview identity/connectivity and explicit server/node links. Transient registration or installation refresh failures retain loaded tab content and drafts while showing Retry; authentication failure blocks it. +- Added keyed Configuration with local-only kubeconfig download, local/config:read storage settings, destination management and a separate confirmed registration removal section. Late credential responses cannot initiate a download after leaving that target. Successful removal clears only a matching remembered selection, invalidates registry/fleet data and returns to Clusters. +- Extracted destination CRUD with explicit cluster and `gameplane-games` namespace, corresponding permission checks and query keys. Deletion still requires the exact destination name; its failures remain in the confirmation and clear on dismissal. Target changes discard passwords and mutation errors; pending requests retain their captured target. +- Updated directory actions to Open cluster, View servers and capability-gated View nodes. Removed direct directory removal and the inventory-or-manager-only page denial; the backend's registry remains the access boundary. +- Removed backup destinations from panel Settings navigation. `/admin?section=backups` preserves the local Configuration destination on combined installations; standalone uses a valid remembered remote. Missing targets link to the cluster directory. This compatibility view issues no destination request and never assumes a local standalone workload. + +Regression sources preserve the previous download/storage and destination assertions in their new owning component tests. Added route-versus-storage targeting, unavailable/unauthorized registration, destination namespace grants, wrong-cluster mutation denial, pending request isolation, download disposal and registration removal coverage. Existing standalone module assertions remain; node inventory/enrollment coverage is owned by the Nodes task. + +The root task is preserving discoverability for destination/config-only roles in the existing registry authorization and updating router/sidebar integration. No new endpoint or remote cluster operation was introduced here. + +Validation: no local tests or lint suites were run. This checkout has no local dependency installation; `npx tsc --noEmit` could not invoke TypeScript (npx selected the unrelated `tsc` package). Root's Docker `tsc -b` identified unsupported `exact` role-query options in the new tests; those options were removed. Await root's repeat compilation and CI for final verification. No commits made by this subtask. diff --git a/specs/031-cluster-nodes-navigation/nodes-report.md b/specs/031-cluster-nodes-navigation/nodes-report.md new file mode 100644 index 000000000..7991518eb --- /dev/null +++ b/specs/031-cluster-nodes-navigation/nodes-report.md @@ -0,0 +1,19 @@ +# Shared Nodes implementation report + +Implemented `NodesPage`, shared `NodeInventory`, target-keyed `NodeEnrollment`, and pure flatten/filter/sort/summary helpers. Fleet queries preserve explicit cluster scope and the global authorized scope list. Inventory uses composite cluster/node identities, responsive table or stacked rows, search/status/cluster filters, all seven sortable columns, capacity-weighted summaries, unknown metrics, and visible partial/error coverage. + +Enrollment from All clusters requires a target choice before any mutation. Remote targets show guidance; local joins require installation capabilities, cluster-management permission, and enabled operations. Route or target changes discard commands and pending UI results. Older APIs retain the existing capability fallback. No direct local capability request occurs before a local workload is confirmed. + +Regression sources were written before implementation. No local test or lint suite was run. Root is running the permitted TypeScript container compile; its initial compile identified React Intl descriptor inference errors, resolved using the existing `MessageDescriptor` registry pattern. Final compile and browser verification are recorded by the integration owner. + +## Legacy coverage migration + +- `routes/Cluster.test.tsx` inventory cases moved to `components/nodes/NodeInventory.test.tsx`: late local response isolation, node fields/roles/labels/status/uptime, unknown versus zero, multiple rows, filtered summaries, empty/error/retry behavior. +- Enrollment cases moved to `components/nodes/NodeEnrollment.test.tsx`: command generation, 501/403/500 handling, retry clears errors, copy/dismiss, disabled capabilities, old API fallback, local-result disposal, remote guidance, standalone and permissions. +- Download/storage cases moved by the configuration agent to `components/clusters/ClusterConfiguration.test.tsx`. +- Old grid/card selectors and the single-cluster version subtitle were replaced by table assertions and filtered summaries. Node roles are displayed only when reported, rather than inventing a worker role. Cluster version belongs to the detail Overview. +- `lib/nodes.test.ts` covers identities, combined search/status, numeric ordering with unknown values last, invalid/zero measurements, and weighted summaries. + +Migrated E2E sources: `cluster.spec.ts`, `multicluster-ui.spec.ts`, `shell.spec.ts`, `screenshots.spec.ts`, `errorHandling.spec.ts`, and live `dataScreens.spec.ts` / `login-and-shell.spec.ts`. They cover Nodes navigation, all/filtered clusters, responsive bounds, local enrollment versus per-cluster configuration, legacy `/cluster` redirect, unchanged remembered selection, and inventory failure coverage. API/cache/privacy tests retain `/cluster` where it denotes the backend endpoint. E2E suites remain CI-only. + +No commits were made by this task owner. diff --git a/specs/031-cluster-nodes-navigation/plan.md b/specs/031-cluster-nodes-navigation/plan.md new file mode 100644 index 000000000..500ebceba --- /dev/null +++ b/specs/031-cluster-nodes-navigation/plan.md @@ -0,0 +1,87 @@ +# Cluster and node navigation implementation plan + +**Goal:** Implement the approved cluster details and all-cluster Nodes experience and run the branch locally. + +**Architecture:** Reuse existing fleet inventory and cluster endpoints. One shared NodeInventory component serves the Nodes page and cluster detail Nodes tab. Extract target-bound configuration panels from legacy Cluster and AdminSettings screens. + +**Tech Stack:** React 19, TypeScript, TanStack Query/Router, HeroUI, react-intl, Vitest/MSW regression sources, Docker/Vite mock demo. + +**Spec:** [spec.md](spec.md) + +## Global constraints + +- Design visual changes in Pencil before implementing them; preserve theme tokens and export touched designs. +- No local test or lint suites; compile/build and interactive inspection only, suites in CI. +- Reuse existing endpoints; extend registration metadata discovery only for existing destination/local configuration permissions. No new dependency required. +- Every workload operation captures its explicit cluster; standalone never invents local. +- Use human Git author and signed/sign-off commits; no AI attribution. +- Branch `feat/cluster-nodes-navigation`, push to `Baylem/Gameplane`, never merge. + +## Review focus + +- Duplicate node names in different clusters must not collapse or cross-target links. +- Unavailable/truncated clusters remain filterable and coverage is visibly incomplete. +- Missing/invalid metrics differ from measured zero, including sort behavior. +- Cluster changes while an enrollment/destination request is pending cannot leak results or drafts. +- Remote-only permissions, standalone emptiness and unsupported operations retain their boundaries. + +## Task 1: Canonical Pencil designs + +- [x] Update Sidebar and Settings nav and cluster/location terminology. +- [x] Design fleet Nodes desktop/mobile, cluster Overview / Nodes / Configuration and directory entry paths. +- [x] Inspect screenshots and layout bounds; export touched roots as PNG and JSON, update manifest. +- [x] Save design.pen through Pen and verify its Git status. + +## Task 2: Shared nodes inventory + +Files: new `web/src/pages/Nodes.tsx`, `web/src/components/nodes/NodeInventory.tsx`, `web/src/components/nodes/NodeEnrollment.tsx`, `web/src/lib/nodes.ts`, colocated tests. + +Interfaces: +- `NodesPage()` reads useFleetLocation and renders `NodeInventory`. +- `NodeInventory({clusterId?, onClusterChange?, embedded?})`: omitted/empty cluster means all; fixed embedded cluster has no global filter. `onClusterChange(value: string)` updates the Nodes URL. Export for ClusterDetail. +- `NodeEnrollment({clusterId?: string})`: explicit target selection for all-cluster action, local-only join, permission checks, keyed results on selection changes. +- Fleet `inventory(filter?: {cluster?: string}, signal?: AbortSignal)` and result scopes/issues are existing APIs. + +- [x] Write regression sources before UI implementation for duplicate names, all/default and exact-cluster requests, search/status/sort, partial inventory, unknown vs zero and target-bound enrollment. +- [x] Implement flatten/filter/sort helpers without rewriting node names. +- [x] Translate approved design to shared responsive table, summaries, controls, coverage, retry and empty states. +- [x] Reuse target-bound Add node and operation error behavior; preserve disabled/remote semantics and copy command behavior. +- [x] Compile new sources; report validation without running suites. + +## Task 3: Cluster details and configuration + +Files: new `web/src/pages/ClusterDetail.tsx`, `web/src/components/clusters/ClusterConfiguration.tsx`, `web/src/components/clusters/BackupDestinationsPanel.tsx`, update `web/src/routes/Clusters.tsx`, `web/src/routes/Cluster.tsx`, `web/src/routes/AdminSettings.tsx`, `web/src/components/ui/SettingsNav.tsx`, colocated tests. + +Interfaces: +- `ClusterDetailPage()` reads `/clusters/$clusterId` and search `tab?: overview|nodes|configuration`; explicit registry identity only. +- `ClusterConfiguration({cluster: ClusterRegistry})` preserves local capability/permission limits and separate removal confirmation. +- `BackupDestinationsPanel({clusterId: string})` captures exact target for CRUD and queries `['backup-destinations', clusterId]`. +- Consume shared `NodeInventory({clusterId, embedded:true})` from task 2. + +- [x] Write/relocate behavioral tests for configuration, enrollment/download errors, destination target/permissions, delete failure, explicit standalone selection and stale response isolation. +- [x] Implement cluster detail shell, overview and shared Nodes tab. +- [x] Extract kubeconfig/storage/destination controls; remove the legacy controls from old screens. +- [x] Make Clusters entries open details and filtered Nodes; move removal to configuration. +- [x] Remove backup destination Settings nav and preserve legacy deep links through a clear owning-cluster link/redirect. +- [x] Keep Cluster.tsx compatibility export/redirect thin; preserve old regressions in new owning components. + +## Task 4: Navigation, terminology, integration and delivery + +Files: `web/src/router/tree.tsx`, `web/src/components/AppLayout.tsx`, `web/src/components/ui/Breadcrumbs.tsx`, `web/src/components/FleetScope.tsx`, other user-facing location strings/tests, English catalog, docs, browser mock data if needed. + +- [x] Add `/nodes?cluster=` and `/clusters/$clusterId?tab=` routes without incorrect local inventory guards; preserve `/cluster` links. +- [x] Change sidebar Cluster to Nodes and route resource drill-down links to Nodes with explicit filters. Keep central-management banner off cluster detail/configuration pages. +- [x] Replace cluster-related Location labels, using Cluster / namespace for combined placement choices; retain non-cluster uses. +- [x] Regenerate English catalog with existing extraction workflow; update relevant docs and tests. +- [x] Compile/build, obtain independent diff review and fix material findings. +- [x] Signed commit and verify signature; push feature branch to personal fork; inspect applicable CI. +- [x] Run branch demo in Docker with coherent multi-cluster fixtures and inspect desktop/mobile Nodes, filters, details/configuration and navigation in browser. +- [x] Deliver branch URL, local URL, verification results and remaining limitations. + +## Follow-up: Match the Backups filter toolbar + +- [x] Update canonical fleet desktop/mobile and cluster Nodes designs, export touched nodes, and save through Pen. +- [x] Reuse the shared FilterPopover with draft Cluster/Status fields and an applied-filter count; match Backups search sizing and preserve fixed cluster scope. +- [x] Update behavioral assertions for Apply/Clear/dismissal and browser navigation; compile locally and inspect the running demo. +- [ ] Validate the follow-up suites through CI. +- [ ] Sign and push the follow-up on the existing feature branch and refresh its local Docker preview. diff --git a/specs/031-cluster-nodes-navigation/progress.md b/specs/031-cluster-nodes-navigation/progress.md new file mode 100644 index 000000000..3d2799b04 --- /dev/null +++ b/specs/031-cluster-nodes-navigation/progress.md @@ -0,0 +1,34 @@ +# Progress — cluster and node navigation + +- Base: a536896ca119c6b4bb4c55037cfdb745bf481c4e (upstream master, 2026-11-09). +- Branch: feat/cluster-nodes-navigation in fresh isolated clone D:/Codex/Gameplane/demo-20261109. +- User explicitly approved implementation, personal-fork branch and local demo after design discussion; no additional implementation approval is needed. +- Pencil file opened by user; designer owns canvas and exports. +- Existing read-only Docker baseline: gameplane-ui-demo-a536896c on localhost:5173. It copied base source into container and will not automatically include feature changes. +- Ruling: Use a fresh isolated clone already created for this task rather than another nested worktree; it protects the user's existing checkouts. +- Ruling: Repository no-local-suites rule overrides skill TDD execution; write meaningful behavioral regressions, compile locally, use CI for suites. +- Ruling: User's explicit no-AI-attribution signing instructions override the repository's contradictory co-author trailer instruction. +- Tasks: design in progress; nodes/configuration scouts complete; implementation pending design baseline. +- Nodes design baseline ready: BdmAW, reusable table I38x1; node implementation in progress. +- Ruling: Relocation must not lock out existing destination/configuration roles. Extend registration metadata discovery for target-bound destination permissions and local-only central configuration permissions; retain existing inventory authorization and add handler regression cases. + +- Pen designs saved by user after final dashboard and column adjustments. 54 JSON/PNG export pairs and manifest completed. +- Tasks 1–3 implemented; reviewers identified legacy standalone redirect, tab breadcrumbs, legacy backup-link ownership and test-provider issues; all fixed. +- Local compile passed: Go auth/handlers, TypeScript project and E2E sources. Production frontend build passed using valid language packs (existing Vite chunk-size warning). No test/lint suites run locally. +- English catalog regenerated; 72 obsolete/stale French entries removed for documented fallback; remaining 2,592 translations retain review state (93.54% coverage). +- Branch demo gameplane-ui-cluster-nodes runs at localhost:5174. Desktop 1440px and mobile 375px Nodes inspected; multi-cluster fixtures return two independent rows. Configuration interaction and CI follow-up pending. + +- Signed design and implementation commits verified and pushed to personal fork. Full CI dispatched (42651832916). CI found three untranslated JSX literals; fixed with Intl messages and a machine-setting constant. Hidden Select-option status assertions scoped to their node rows. +- Desktop/mobile interactive inspection completed: all nodes and exact remote filter, directory details, active-tab breadcrumbs, remote disabled/local enabled access, scoped Nodes tab, and all-cluster sidebar return. No browser console errors observed. Demo server sends the existing multi-cluster fixture cookie for predictable initial review. +- A draft PR in the personal fork will run normal changed-path CI for follow-up commits; no merge or upstream PR. + +- CI on ede0434 passed i18n, ESLint and build; 3,014 unit tests passed and 81 browser tests passed. Remaining failures were old terminology, early loading assertions and hidden-option selectors; fixed without dropping behavior coverage. Kubeconfig now also stays disabled while the initial capability request is pending. +- Migrated legacy screenshot routes to OecJ5/T70dJ cluster Configuration and added BdmAW/N2nTf Nodes captures. Existing screenshot data is local/three ready nodes; remote and enrollment design exports remain outside this fixture's captures. +- Fork CI dependency review is blocked because Dependency graph is disabled in Baylem/Gameplane; workflow was not weakened. Draft PR: https://github.com/Baylem/Gameplane/pull/13. + +- CI f487a9c: all 3,017 unit tests and coverage, 83 mock E2E tests, API lint/tests on amd64 and arm64, and 124 en-XA screenshot/clipping cases passed. Visual comparison passed 115/116; mobile Nodes exceeded its threshold at 4.01% vs 4.00%. Inspection found oversized mobile summary cards relative to the canonical compact design. Replaced them with a compact ready/reporting row and side-by-side filters; retained desktop cards and per-node metrics. Interactive 390px inspection and TypeScript compile passed; added mobile behavior regression. + +- Prior delivery 2a79eae passed 3,018 web tests/coverage, 83 mock E2E cases, 116 visual comparisons and 124 en-XA cases. Remaining CI infrastructure limitations were recorded in the draft PR. +- User requested the Backups toolbar pattern for Nodes: compact plain search, Cluster/Status inside a single Filter popover, and applied-filter count. Updated the canonical desktop/mobile/scoped Nodes designs and enrollment background plus three reusable components; seven export pairs updated, and the user saved the canonical Pen file. +- Implemented shared FilterPopover draft/Apply/Clear semantics; immediate search is excluded from the badge, and embedded Nodes offers Status only. Updated unit and browser regressions without removing prior behavior coverage. Independent review found no actionable issues. +- TypeScript project/E2E compilation and production frontend build passed. Interactive desktop/mobile review verified two-filter count, deferred URL changes, Clear preserving search, popup fit, and embedded fixed scope. Docker preview contains the updated code. No local test/lint suites were run. diff --git a/specs/031-cluster-nodes-navigation/spec.md b/specs/031-cluster-nodes-navigation/spec.md new file mode 100644 index 000000000..c495b7f21 --- /dev/null +++ b/specs/031-cluster-nodes-navigation/spec.md @@ -0,0 +1,42 @@ +# Cluster and node navigation + +## Intent + +Implement the user's approved discussion: keep Clusters, replace the ambiguous singular Cluster navigation with Nodes, show all authorized nodes by default with a cluster filter, give each cluster Overview / Nodes / Configuration, and move cluster-owned configuration out of panel settings. Use Cluster wherever the product currently calls a Kubernetes cluster a Location. + +## Design + +Pencil `design.pen` is the visual source. Retain the established sidebar, typography, theme tokens and HeroUI controls. Clusters remains a registration/connectivity directory. Each entry opens cluster details and links directly to its nodes. The Nodes sidebar entry always opens the all-cluster inventory, independently of remembered infrastructure selection. + +Nodes has a title and description, filter-responsive summary counts, search, Cluster and Status filters, and a sortable table with Node, Cluster, Status, CPU, Memory, Pods and Uptime. On narrow screens use compact stacked node rows without horizontal page overflow. Preserve node roles/labels in secondary information. Cluster detail Nodes reuses the same inventory component, fixed to that route's cluster. No duplicated inventory implementation. + +The search and filter toolbar follows Backups: a compact search input and one Filter button. Its badge counts applied Cluster and Status filters, excluding search and the fixed cluster in an embedded inventory. The popover stages changes until Apply; Clear resets the draft choices, and dismissal discards unapplied changes. Search continues to filter immediately. This refines the existing design at the user's request without changing inventory or sorting behavior. + +Cluster details use explicit route identity with tabs Overview, Nodes and Configuration. Overview shows registration identity, API connectivity, version and links to servers/nodes. Configuration contains Access (download kubeconfig when supported), Storage (read-only install-time class when available), Backup destinations (existing CRUD), and a separate removal section. Registration removal retains confirmation and leaves workloads intact. Add node remains in Nodes; from All clusters it requires an explicit target before minting any join command. Only the local cluster supports current join/kubeconfig operations. + +Panel settings retains panel-owned sections. Remove backup destinations from its navigation; preserve old deep links with a clear path to the owning cluster. Preserve the old /cluster URL as a compatibility redirect. Navigation and breadcrumbs identify the active cluster and tab, and cluster IDs in URLs are encoded by the router. + +## Data and authorization + +- Reuse `/fleet/inventory`, `Clusters.list`, and existing cluster/configuration/destination endpoints. No new endpoint or workload capability is implied. Preserve access to relocated settings by allowing registration metadata discovery for existing destination grants on their target clusters and local installation configuration grants; this never grants node inventory or remote configuration access. +- Default Nodes requests all authorized inventory; a cluster filter requests that exact backend cluster, including clusters omitted by a combined limit. +- Node identity is the pair of cluster registration ID and node name. Same names in different clusters remain separate. +- Filter choices retain authorized scopes, unavailable clusters and the selected cluster. Partial/error data is visible, never presented as a complete healthy/empty fleet. +- Missing CPU, memory or pod measurements render unknown; measured zero remains zero. Summaries follow filters and disclose partial coverage. +- Check inventory using the registration's canViewInventory and the fleet API's authorization; do not hide remote-only grants behind a local/global inventory guard. +- Mutations retain the route or explicitly captured cluster throughout asynchronous work. Switching clusters discards sensitive drafts/results. Never infer a local workload on standalone installs. +- Destination permissions and requests are target-bound. Download, enrollment and install-time storage keep their existing local/permission/capability limitations. + +## Terminology + +Change user-facing cluster/location vocabulary in navigation, filters, creation, status/error text, search and accessible labels. A placement containing both cluster and namespace is labeled Cluster / namespace where needed. Browser window.location, storage paths, HTTP Location headers and unrelated geographic meanings stay unchanged. Refresh English i18n source and affected tests/docs. + +## Alternatives considered + +1. Rename only: removes the naming collision but leaves configuration scattered and nodes restricted to one remembered cluster. +2. Nest every node visit under Clusters: clear ownership but adds steps for frequent monitoring. +3. Chosen: direct fleet Nodes plus cluster detail tabs, sharing the inventory view. This follows the user's all-clusters requirement and retains quick access. + +## Validation and delivery + +Add behavioral regressions for filtering, duplicate identity, unknown and partial metrics, route-bound mutations and capability restrictions. Repository policy permits local compile/build checks and interactive browser inspection, but test/lint suites run only in CI. Preserve existing regression assertions when relocating them. Commit with the user's unattended signing configuration and human attribution, push the feature branch to Baylem/Gameplane, and run its demo in Docker locally for review. No merge or upstream publication is requested. diff --git a/web/e2e/screenshots/slice4.spec.ts b/web/e2e/screenshots/slice4.spec.ts index a07f9b672..1776fa2b0 100644 --- a/web/e2e/screenshots/slice4.spec.ts +++ b/web/e2e/screenshots/slice4.spec.ts @@ -2,7 +2,7 @@ import { test, expect, type Page, type BrowserContext } from "@playwright/test"; import { capture, captureLocator } from "./capture"; // T159 (specs/done_014-heroui-web-rebuild/tasks.md): Slice 4 — Admin Settings, -// Users & RBAC, Audit Log, System Logs, and Cluster Settings. Screenshot +// Users & RBAC, Audit Log, System Logs, and Cluster Configuration. Screenshot // verification tests for the HeroUI rebuild, capturing the design frames // listed in specs/done_014-heroui-web-rebuild/contracts/component-map.md's // "Slice 4" entry / assets/design-export/MANIFEST.md, at 1440px, for comparison @@ -33,12 +33,22 @@ import { capture, captureLocator } from "./capture"; // configured state (uMiwd/R65Xyx/Rwnu3/XL5ZU/vStkb/uw0dB), the "no OIDC // mappings yet" empty state (nNGDX/BV5ei), the "save rejected" state // (QgW58), the failed audit-chain banner (kIxaJ), and the empty default -// storage class (dxdEi) are one-off variants selected per-test via a +// storage class (T70dJ) are one-off variants selected per-test via a // context.addCookies() cookie read inside buildScreenshotHandlers() // (src/test/handlers.ts) — see setAdminConfigVariant()'s comment below for // why a cookie, not a page.route override: those endpoints are answered by // MSW's Service Worker (msw/browser), and Playwright cannot intercept a // request a Service Worker's own fetch handler has already resolved. +// +// Cluster/navigation migration: OecJ5 replaces both the legacy j9W8A +// Cluster Settings and RC3Kf Admin Backup destinations captures at +// /clusters/local?tab=configuration; T70dJ replaces dxdEi at the same route +// with empty-storage-class. BdmAW and N2nTf capture /nodes on desktop/mobile. +// The existing screenshot fixture supplies one local cluster and three ready +// nodes, so these captures do not reproduce the references' partial fleet or +// remote rows. Remote Configuration t56aLu and enrollment-modal IBUqJ remain +// exported-only: this fixture has no remote registration, and enrollment is +// currently an inline card rather than the modal shown in that reference. // Full-page capture for this spec's routed-screen ids. Delegates to the // shared web/e2e/screenshots/capture.ts helper (viewport sized to match the @@ -330,14 +340,6 @@ test.describe("Slice 4: Admin, Users, Audit, System logs, Cluster (Desktop — 1 await captureLocator(page, "Kp48V", dialog); }); - test("RC3Kf: Admin Settings — Backup destinations", async ({ page }) => { - await page.goto("/admin"); - await clickSection(page, "Backup destinations"); - await expect(page.getByText(/restic repositories/i)).toBeVisible({ timeout: 10_000 }); - await page.waitForTimeout(200); - await capture(page, "RC3Kf"); - }); - test("g5mEpx: Admin Settings — Module sources", async ({ page }) => { await page.goto("/admin"); await clickSection(page, "Module sources"); @@ -556,27 +558,57 @@ test.describe("Slice 4: Admin, Users, Audit, System logs, Cluster (Desktop — 1 await capture(page, "Bq2Yg"); }); - test("j9W8A: Cluster Settings", async ({ page }) => { - await page.goto("/cluster"); - await expect(page.getByRole("heading", { name: /cluster/i }).first()).toBeVisible({ + test("OecJ5: Cluster Details — Configuration — Local", async ({ page }) => { + await page.goto("/clusters/local?tab=configuration"); + await expect(page.getByRole("tab", { name: "Configuration", exact: true })).toHaveAttribute("aria-selected", "true", { timeout: 10_000, }); - // Cluster.tsx's

Storage

— a plain getByText("Storage") also - // matches non-heading wrapper elements whose only visible child is - // that h3, so scope to the heading role for a unique match. - await expect(page.getByRole("heading", { name: "Storage" })).toBeVisible(); - await expect(page.getByText("fast-nvme")).toBeVisible(); + await expect(page.getByRole("button", { name: /download kubeconfig/i })).toBeEnabled(); + await expect(page.getByRole("region", { name: "Storage", exact: true }).getByText("fast-nvme")).toBeVisible(); + const destinations = page.getByRole("region", { name: "Backup destinations", exact: true }); + await expect(destinations.getByText(/restic repositories/i)).toBeVisible(); + await expect(destinations.getByText("default", { exact: true })).toBeVisible(); await page.waitForTimeout(200); - await capture(page, "j9W8A"); + await capture(page, "OecJ5"); }); - test("dxdEi: Cluster Settings — Default storage class", async ({ page, context }) => { + test("T70dJ: Cluster Details — Configuration — Default storage", async ({ page, context }) => { await setAdminConfigVariant(context, "empty-storage-class"); - await page.goto("/cluster"); - // See j9W8A's note on scoping to the heading role. - await expect(page.getByRole("heading", { name: "Storage" })).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText("Cluster default")).toBeVisible(); + await page.goto("/clusters/local?tab=configuration"); + await expect(page.getByRole("region", { name: "Storage", exact: true }).getByText("Cluster default")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("button", { name: /download kubeconfig/i })).toBeEnabled(); + await expect(page.getByRole("region", { name: "Backup destinations", exact: true }).getByText("default", { exact: true })).toBeVisible(); + await page.waitForTimeout(200); + await capture(page, "T70dJ"); + }); + + test("BdmAW: Nodes — All clusters", async ({ page }) => { + await page.goto("/nodes"); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("kubelab-worker-2", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Sort by Node", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeEnabled(); + await page.waitForTimeout(200); + await capture(page, "BdmAW"); + }); +}); + +test.describe("Slice 4: Nodes (Mobile — 390px) @screenshots", () => { + test.use({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + colorScheme: "dark", + }); + + test("N2nTf: Nodes — All clusters — Mobile", async ({ page }) => { + await useScreenshotDataset(page); + await page.goto("/nodes"); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible({ timeout: 10_000 }); + const inventory = page.getByRole("list", { name: "Node inventory", exact: true }); + await expect(inventory.getByRole("listitem")).toHaveCount(3); + await expect(inventory.getByText("kubelab-worker-2", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeEnabled(); await page.waitForTimeout(200); - await capture(page, "dxdEi"); + await capture(page, "N2nTf"); }); }); diff --git a/web/e2e/specs/cluster.spec.ts b/web/e2e/specs/cluster.spec.ts index 7d435cc36..0da026709 100644 --- a/web/e2e/specs/cluster.spec.ts +++ b/web/e2e/specs/cluster.spec.ts @@ -17,7 +17,7 @@ async function loginIfNeeded(page: Page): Promise { } } -test.describe("cluster page", () => { +test.describe("nodes and cluster configuration", () => { // Mock mode serves a deterministic 3-node view. Live mode's cluster data // depends on clusterOps being enabled on the e2e cluster, so the node // summary isn't guaranteed there — keep this spec deterministic. @@ -30,18 +30,32 @@ test.describe("cluster page", () => { await loginIfNeeded(page); }); - test("renders the cluster header and node summary", async ({ page }) => { - await page.goto("/cluster"); - await expect(page).toHaveURL(/\/cluster$/); - await expect(page.getByRole("heading", { name: /cluster/i }).first()).toBeVisible(); - // The header subtitle always reports node health ("X/Y nodes healthy"), - // independent of how many nodes the install actually has. - await expect(page.getByText(/nodes healthy/i)).toBeVisible(); + test("renders the Nodes header and filtered node summary", async ({ page }) => { + await page.goto("/nodes"); + await expect(page).toHaveURL(/\/nodes$/); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible(); + await expect(page.getByText("Ready nodes", { exact: true })).toBeVisible(); + await expect(page.getByText("Reporting clusters", { exact: true })).toBeVisible(); + await expect(page.getByRole("grid", { name: "Node inventory" })).toBeVisible(); }); - test("exposes the cluster operation actions", async ({ page }) => { - await page.goto("/cluster"); + test("keeps enrollment in Nodes and kubeconfig in the owning cluster configuration", async ({ page }) => { + await page.goto("/nodes?cluster=local"); await expect(page.getByRole("button", { name: /add node/i })).toBeVisible(); + await page.goto("/clusters/local?tab=configuration"); await expect(page.getByRole("button", { name: /download kubeconfig/i })).toBeVisible(); }); + + test("preserves a legacy inventory bookmark while the Nodes sidebar opens all clusters", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("gameplane.cluster", "local")); + await page.goto("/cluster"); + await expect(page).toHaveURL(/\/nodes\?cluster=local$/); + const sidebar = page.getByRole("navigation", { name: "Primary" }); + const nodes = sidebar.getByRole("link", { name: "Nodes", exact: true }); + await expect(nodes).toHaveAttribute("href", "/nodes"); + await nodes.click(); + await expect(page).toHaveURL(/\/nodes$/); + await page.getByRole("button", { name: "Filter", exact: true }).click(); + await expect(page.getByRole("button", { name: /Filter by cluster/ })).toContainText("All clusters"); + }); }); diff --git a/web/e2e/specs/errorHandling.spec.ts b/web/e2e/specs/errorHandling.spec.ts index 33f87c065..3ab725b3f 100644 --- a/web/e2e/specs/errorHandling.spec.ts +++ b/web/e2e/specs/errorHandling.spec.ts @@ -44,7 +44,7 @@ test.describe("error handling", () => { await page.waitForURL((u) => u.pathname.startsWith("/login"), { timeout: 10_000 }); }); - test("500 on /cluster shows an error UI rather than a blank screen", async ({ page }) => { + test("failed cluster inventory shows incomplete coverage on Nodes", async ({ page }) => { // Cookie-selected, not page.route: MSW's Service Worker answers // /cluster and /cluster/stats itself in mock mode (see handlers.ts's // e2e_cluster_500 branch). @@ -52,22 +52,13 @@ test.describe("error handling", () => { { name: "e2e_cluster_500", value: "1", url: "http://localhost:5173" }, ]); await loginIfNeeded(page); - await page.goto("/cluster"); + await page.goto("/nodes"); await page.waitForLoadState("domcontentloaded"); - // The page must render *something* — heading or any visible text — - // not a blank page, and it must be the error UI, not still the loading - // skeleton. Poll instead of a single read to avoid a race against the - // SPA's async data-fetch-then-render cycle. A detailed error-card - // assertion lives in the Cluster component test. - await expect.poll( - async () => { - const skeleton = await page.locator('[role="status"][aria-label="Loading"]').count(); - if (skeleton > 0) return ""; - return page.locator("body").innerText(); - }, - { timeout: 10_000 }, - ).not.toBe(""); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible(); + await expect(page.getByText("Node results are partial.", { exact: true })).toBeVisible(); + await expect(page.getByText("No node data is available for this scope.", { exact: true })).toBeVisible(); + await expect(page.getByText("0 / 0", { exact: true })).toHaveCount(0); }); test("pre-auth /login does not call /users or /cluster", async ({ page }) => { @@ -78,7 +69,8 @@ test.describe("error handling", () => { u.pathname === "/users" || u.pathname === "/cluster" || u.pathname === "/cluster/info" || - u.pathname === "/cluster/stats" + u.pathname === "/cluster/stats" || + u.pathname === "/fleet/inventory" ) { forbidden.push(`${req.method()} ${u.pathname}`); } diff --git a/web/e2e/specs/live/dataScreens.spec.ts b/web/e2e/specs/live/dataScreens.spec.ts index 4ee478c71..ba2f85aad 100644 --- a/web/e2e/specs/live/dataScreens.spec.ts +++ b/web/e2e/specs/live/dataScreens.spec.ts @@ -49,13 +49,15 @@ test.describe("live: data screens render real backend data", () => { await loginIfNeeded(page); }); - test("cluster page shows real node health", async ({ page }) => { - await page.goto("/cluster"); - await expect(page).toHaveURL(/\/cluster$/); - await expect(page.getByRole("heading", { name: /cluster/i }).first()).toBeVisible(); - // "X/Y nodes healthy" is computed from the live ClusterView (real K8s - // node list) — the kind cluster always has at least its control-plane. - await expect(page.getByText(/nodes healthy/i)).toBeVisible(); + test("Nodes shows real node health", async ({ page }) => { + await page.goto("/nodes"); + await expect(page).toHaveURL(/\/nodes$/); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible(); + await expect(page.getByText("Ready nodes", { exact: true })).toBeVisible(); + // The kind cluster always returns at least its control-plane node. + const table = page.getByRole("grid", { name: "Node inventory" }); + await expect(table).toBeVisible(); + await expect.poll(() => table.getByRole("row").count()).toBeGreaterThan(1); }); test("users page lists the bootstrapped admin and real builtin roles", async ({ page }) => { diff --git a/web/e2e/specs/live/i18n.spec.ts b/web/e2e/specs/live/i18n.spec.ts index 2ea96255c..898ffe52b 100644 --- a/web/e2e/specs/live/i18n.spec.ts +++ b/web/e2e/specs/live/i18n.spec.ts @@ -44,10 +44,9 @@ async function pickFrenchFromCompactPicker(page: Page): Promise { await page.getByRole("menuitem", { name: "Français" }).click(); } -// watchForEnglish records whether the English servers-page subtitle ever shows -// on the page, from before React renders. Install it before the first goto. -// The subtitle is the anchor, not the "Create server" button, because that -// button only renders when the user has a placement to create in. +// watchForEnglish records whether the English Servers h1 ever shows in main, +// from before React renders. Install it before the first goto. This title has +// a French translation; newer subtitles can legitimately fall back to English. async function watchForEnglish(page: Page): Promise { await page.addInitScript(() => { const w = window as I18nWindow; @@ -55,7 +54,7 @@ async function watchForEnglish(page: Page): Promise { // Observe from the init script itself: module scripts can render before // DOMContentLoaded, so waiting for it could miss a short English flash. const check = () => { - if (/Manage your game servers/.test(document.documentElement?.textContent ?? "")) { + if (document.querySelector("main h1")?.textContent?.trim() === "Servers") { w.__englishSeen = true; } }; @@ -112,13 +111,13 @@ test.describe("live: i18n", () => { // Client-side navigation to Servers keeps the marker and shows French there. await page.getByRole("link", { name: "Serveurs" }).first().click(); await expect(page).toHaveURL(/\/servers$/); - await expect(page.getByText("Gérez vos serveurs de jeu").first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("main").getByRole("heading", { level: 1, name: "Serveurs", exact: true })).toBeVisible({ timeout: 15_000 }); expect(await page.evaluate(() => (window as I18nWindow).__i18nMark)).toBe(1); }); test("reload and a new browser context keep French without an English flash", async ({ page, browser }) => { - // Watch the DOM from before React renders. A later English subtitle is - // English leaking into the French page. + // Watch the translated page title from before React renders. An English + // Servers h1 indicates a language flash even if the subtitle uses fallback. await watchForEnglish(page); await page.goto("/servers"); @@ -127,7 +126,7 @@ test.describe("live: i18n", () => { await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.locator("html")).toHaveAttribute("lang", "fr"); - await expect(page.getByText("Gérez vos serveurs de jeu").first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("main").getByRole("heading", { level: 1, name: "Serveurs", exact: true })).toBeVisible({ timeout: 15_000 }); expect(await page.evaluate(() => (window as I18nWindow).__englishSeen)).toBe(false); // A new browser context has no local storage, so the account choice decides. @@ -138,7 +137,7 @@ test.describe("live: i18n", () => { await p2.goto("/servers"); await p2.waitForLoadState("domcontentloaded"); await expect(p2.locator("html")).toHaveAttribute("lang", "fr", { timeout: 15_000 }); - await expect(p2.getByText("Gérez vos serveurs de jeu").first()).toBeVisible({ timeout: 15_000 }); + await expect(p2.getByRole("main").getByRole("heading", { level: 1, name: "Serveurs", exact: true })).toBeVisible({ timeout: 15_000 }); expect(await p2.evaluate(() => (window as I18nWindow).__englishSeen)).toBe(false); } finally { await ctx2.close(); @@ -230,7 +229,8 @@ test.describe("live: i18n", () => { // Every string falls back to English, and no raw message id is shown. await page.goto("/servers"); await page.waitForLoadState("domcontentloaded"); - await expect(page.getByText("Manage your game servers").first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("main").getByRole("heading", { level: 1, name: "Servers", exact: true })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("Manage your game servers across all authorized clusters.", { exact: true })).toBeVisible(); const visible = await page.locator("body").innerText(); expect(visible.split("\n").some((line) => /^web\.[a-z_.]+$/.test(line.trim()))).toBe(false); } finally { diff --git a/web/e2e/specs/live/login-and-shell.spec.ts b/web/e2e/specs/live/login-and-shell.spec.ts index d4de3ec0f..aa70784cd 100644 --- a/web/e2e/specs/live/login-and-shell.spec.ts +++ b/web/e2e/specs/live/login-and-shell.spec.ts @@ -64,7 +64,7 @@ test.describe("live: login and shell", () => { }); test("sidebar navigation renders all main screens for admin", async ({ page }) => { - // Admin sees both the cluster overview and selected-cluster node inventory. + // Admin sees both the cluster directory and all-cluster Nodes inventory. await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await loginIfNeeded(page); @@ -77,7 +77,7 @@ test.describe("live: login and shell", () => { const sidebar = page.getByRole("navigation", { name: "Primary" }); await expect(sidebar).toBeVisible(); - const expectedLinks = ["Clusters", "Servers", "Modules", "Backups", "Cluster", "Users & RBAC", "Audit log", "System logs", "Settings"]; + const expectedLinks = ["Clusters", "Servers", "Modules", "Backups", "Nodes", "Users & RBAC", "Audit log", "System logs", "Settings"]; for (const link of expectedLinks) { const navLink = sidebar.getByRole("link", { name: link, exact: true }); // Verify the nav link is visible @@ -96,7 +96,7 @@ test.describe("live: login and shell", () => { { name: "Servers", path: "/servers" }, { name: "Modules", path: "/modules" }, { name: "Backups", path: "/backups" }, - { name: "Cluster", path: "/cluster" }, + { name: "Nodes", path: "/nodes" }, { name: "Users & RBAC", path: "/users" }, { name: "Audit log", path: "/admin/audit" }, // The "System logs" nav item links to /admin/logs (AppLayout.tsx), diff --git a/web/e2e/specs/live/servers-core.spec.ts b/web/e2e/specs/live/servers-core.spec.ts index 92a82b559..bd58bec8e 100644 --- a/web/e2e/specs/live/servers-core.spec.ts +++ b/web/e2e/specs/live/servers-core.spec.ts @@ -87,7 +87,7 @@ test.describe("live: servers core (list + detail)", () => { await expect(row).toBeVisible({ timeout: 10_000 }); // Reopen the facet filter, whose label includes the applied count. - // The separate "Filter by location" control must not match this locator. + // The separate "Filter by cluster" control must not match this locator. await page.getByRole("button", { name: /^filter(?:\s+\d+)?$/i }).click(); await page.getByRole("button", { name: /^clear$/i }).click(); await page.getByRole("button", { name: /^apply$/i }).click(); diff --git a/web/e2e/specs/multicluster-ui.spec.ts b/web/e2e/specs/multicluster-ui.spec.ts index b7daca5f4..da2e2e77b 100644 --- a/web/e2e/specs/multicluster-ui.spec.ts +++ b/web/e2e/specs/multicluster-ui.spec.ts @@ -5,12 +5,12 @@ const localNode = "gp-demo-central-control-plane"; async function expectInventoryFits(page: Page, nodeName: string) { const main = page.getByRole("main"); - const heading = main.getByRole("heading", { name: "Cluster", exact: true }); + const heading = main.getByRole("heading", { name: "Nodes", exact: true }); const viewport = page.viewportSize()!; for (const control of [ heading, main.getByRole("button", { name: /Add node/i }), - main.getByRole("button", { name: /Download kubeconfig/i }), + main.getByRole("button", { name: /^Filter(?:\s*\d+)?$/ }), ]) { const bounds = await control.boundingBox(); expect(bounds).not.toBeNull(); @@ -25,7 +25,8 @@ async function expectInventoryFits(page: Page, nodeName: string) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); const nodeBounds = await main.getByText(nodeName, { exact: true }).boundingBox(); - const statusBounds = await main.getByText(/^●\s*Ready$/).boundingBox(); + const inventory = main.getByRole(viewport.width < 768 ? "list" : "grid", { name: "Node inventory" }); + const statusBounds = await inventory.getByText("Ready", { exact: true }).boundingBox(); expect(nodeBounds).not.toBeNull(); expect(statusBounds).not.toBeNull(); expect(nodeBounds!.x + nodeBounds!.width).toBeLessThanOrEqual(statusBounds!.x); @@ -60,10 +61,10 @@ test.describe("multicluster administration", () => { await expect(main.getByText("local / gameplane-games", { exact: true })).toBeVisible(); await expect(main.getByText("remote-demo / gameplane-games", { exact: true })).toBeVisible(); - await expect(main.getByRole("button", { name: /Filter by location/ })).toHaveCount(0); + await expect(main.getByRole("button", { name: /Filter by cluster/ })).toHaveCount(0); const filter = main.getByRole("button", { name: /^Filter(?:\s*\d+)?$/ }); await filter.click(); - await page.getByRole("button", { name: /Filter by location/ }).click(); + await page.getByRole("button", { name: /Filter by cluster/ }).click(); await page.getByRole("option", { name: "remote-demo", exact: true }).click(); await Promise.all([ page.waitForRequest((request) => { @@ -78,8 +79,8 @@ test.describe("multicluster administration", () => { expect(await page.evaluate(() => localStorage.getItem("gameplane.cluster"))).toBe("remote-demo"); await filter.click(); - await page.getByRole("button", { name: /Filter by location/ }).click(); - await page.getByRole("option", { name: "All locations", exact: true }).click(); + await page.getByRole("button", { name: /Filter by cluster/ }).click(); + await page.getByRole("option", { name: "All clusters", exact: true }).click(); await page.getByRole("button", { name: "Apply", exact: true }).click(); await expect(links).toHaveCount(2); expect(await main.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); @@ -88,29 +89,40 @@ test.describe("multicluster administration", () => { test(`shows both clusters and selected nodes at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); + await page.addInitScript(() => localStorage.setItem("gameplane.cluster", "remote-demo")); + await page.goto("/nodes"); + await expect(page.getByText(localNode, { exact: true })).toBeVisible(); + await expect(page.getByText(remoteNode, { exact: true })).toBeVisible(); await page.goto("/clusters"); await expect(page.getByRole("heading", { name: "Clusters", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Select cluster", exact: true })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Remote demo", exact: true })).toBeVisible(); await page.getByRole("button", { name: "View nodes in Remote demo", exact: true }).click(); - await expect(page).toHaveURL(/\/cluster$/); - await expect(page.getByRole("main").getByRole("button", { name: "Select cluster", exact: true })).toBeVisible(); + await expect(page).toHaveURL(/\/nodes\?cluster=remote-demo$/); + const nodeFilter = page.getByRole("main").getByRole("button", { name: /^Filter(?:\s*\d+)?$/ }); + await expect(nodeFilter).toHaveAccessibleName(/^Filter\s*1$/); await expect(page.getByText(remoteNode, { exact: true })).toBeVisible(); await expect(page.getByText(localNode, { exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: /Add node/i })).toBeDisabled(); - await expect(page.getByRole("button", { name: /Download kubeconfig/i })).toBeDisabled(); await expectInventoryFits(page, remoteNode); - await page.getByRole("button", { name: "Select cluster", exact: true }).click(); - await page.getByRole("menuitem", { name: "local", exact: true }).click(); + await nodeFilter.click(); + await page.getByRole("button", { name: /Filter by cluster/ }).click(); + await page.getByRole("option", { name: "local", exact: true }).click(); + await expect(page).toHaveURL(/\/nodes\?cluster=remote-demo$/); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page).toHaveURL(/\/nodes\?cluster=local$/); + await expect(nodeFilter).toHaveAccessibleName(/^Filter\s*1$/); await expect(page.getByText(localNode, { exact: true })).toBeVisible(); await expect(page.getByText(remoteNode, { exact: true })).toHaveCount(0); await expectInventoryFits(page, localNode); + await page.goto("/clusters/remote-demo?tab=configuration"); + await expect(page.getByRole("button", { name: /Download kubeconfig/i })).toBeDisabled(); await page.goto("/clusters"); await page.getByRole("button", { name: "View servers in Remote demo", exact: true }).click(); await expect(page).toHaveURL(/\/servers\?cluster=remote-demo$/); await expect(page.getByRole("main").getByRole("link", { name: "smoke-same", exact: true })).toHaveCount(1); await expect(page.getByRole("button", { name: "Select cluster", exact: true })).toHaveCount(0); - expect(await page.evaluate(() => localStorage.getItem("gameplane.cluster"))).toBe("local"); + expect(await page.evaluate(() => localStorage.getItem("gameplane.cluster"))).toBe("remote-demo"); }); } }); diff --git a/web/e2e/specs/screenshots.spec.ts b/web/e2e/specs/screenshots.spec.ts index d193210b8..015eae4b5 100644 --- a/web/e2e/specs/screenshots.spec.ts +++ b/web/e2e/specs/screenshots.spec.ts @@ -206,10 +206,9 @@ test.describe("@screenshots dashboard gallery", () => { test("cluster-nodes", async ({ page }) => { await loginAsAdmin(page); - // CLUSTER PAGE (/cluster): Node list and stats - await page.goto("/cluster"); - // Wait for cluster heading or node list - await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + // Nodes inventory: all authorized clusters, table and filtered summaries. + await page.goto("/nodes"); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible(); await page.waitForTimeout(250); await shoot(page, "cluster-nodes"); }); diff --git a/web/e2e/specs/shell.spec.ts b/web/e2e/specs/shell.spec.ts index bafb5a7bb..e5af6fe87 100644 --- a/web/e2e/specs/shell.spec.ts +++ b/web/e2e/specs/shell.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; import { loginIfNeeded } from "../pages/LoginPage"; -// Shell E2E: covers the top-level navigation, breadcrumbs, cluster selector, +// Shell E2E: covers top-level navigation, breadcrumbs, cluster filters, // and sidebar (desktop fixed + mobile drawer variants). // These tests verify that the application shell components render correctly // and that navigation, cluster switching, and UI interactions work as expected. @@ -187,58 +187,50 @@ test.describe("shell", () => { }); }); - test.describe("infrastructure selector", () => { - test("cluster selector is confined to the inventory page", async ({ page }) => { + test.describe("cluster inventory navigation", () => { + test("Nodes offers its own cluster filter without a global selector", async ({ page }) => { await page.goto("/"); await loginIfNeeded(page); await page.setViewportSize({ width: 1200, height: 800 }); await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /select cluster/i })).toHaveCount(0); - await page.goto("/cluster"); - const clusterSelector = page.getByRole("main").getByRole("button", { name: /select cluster/i }); + await page.goto("/nodes"); + await expect(page.getByRole("button", { name: /Filter by cluster/ })).toHaveCount(0); + await page.getByRole("button", { name: "Filter", exact: true }).click(); + const clusterSelector = page.getByRole("button", { name: /Filter by cluster/ }); await expect(clusterSelector).toBeVisible(); + await expect(clusterSelector).toContainText("All clusters"); }); - test("cluster selector opens dropdown menu", async ({ page }) => { - await page.goto("/cluster"); + test("cluster filter opens authorized cluster choices", async ({ page }) => { + await page.goto("/nodes"); await loginIfNeeded(page); - const clusterSelector = page.getByRole("button", { name: /select cluster/i }); + await page.getByRole("button", { name: "Filter", exact: true }).click(); + const clusterSelector = page.getByRole("button", { name: /Filter by cluster/ }); await clusterSelector.click(); - - // After clicking, a menu should appear. react-aria-components' Menu - // follows the WAI-ARIA menu-button pattern and derives its accessible - // name from the triggering button ("Select cluster") via an automatic - // aria-labelledby, which takes precedence over the DropdownMenu's own - // aria-label="Cluster options" in the accessible-name computation — so - // match the name the menu actually exposes. - const clusterMenu = page.getByRole("menu", { name: /select cluster/i }); - await expect(clusterMenu).toBeVisible(); + await expect(page.getByRole("listbox", { name: /Filter by cluster/ })).toBeVisible(); + await expect(page.getByRole("option", { name: "All clusters", exact: true })).toBeVisible(); }); - test("cluster selector shows health indicator", async ({ page }) => { - await page.goto("/cluster"); + test("cluster directory retains connectivity health indicators", async ({ page }) => { + await page.goto("/clusters"); await loginIfNeeded(page); - - const clusterSelector = page.getByRole("button", { name: /select cluster/i }); - // The button should contain a status dot (rendered as a span with specific classes) - const statusIndicator = clusterSelector.locator("span.h-2.w-2"); - await expect(statusIndicator).toBeVisible(); + await expect(page.getByText(/^API:\s*Healthy$/).first()).toBeVisible(); + await expect(page.getByText(/Status reports Kubernetes API connectivity/)).toBeVisible(); }); - test("cluster dropdown opens the registered clusters overview", async ({ page }) => { - await page.goto("/cluster"); + test("Clusters and Nodes remain separate sidebar destinations", async ({ page }) => { + await page.goto("/nodes?cluster=local"); await loginIfNeeded(page); - - const clusterSelector = page.getByRole("button", { name: /select cluster/i }); - await clusterSelector.click(); - - const viewClustersItem = page.getByRole("menuitem", { name: /view all clusters/i }); - await expect(viewClustersItem).toBeVisible(); - await viewClustersItem.click(); + const sidebar = page.getByRole("navigation", { name: "Primary" }); + await sidebar.getByRole("link", { name: "Clusters", exact: true }).click(); await expect(page).toHaveURL(/\/clusters$/); await expect(page.getByRole("heading", { name: "Clusters", exact: true })).toBeVisible(); + await sidebar.getByRole("link", { name: "Nodes", exact: true }).click(); + await expect(page).toHaveURL(/\/nodes$/); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible(); }); }); diff --git a/web/specs.md b/web/specs.md index bf1749378..80a8985f0 100644 --- a/web/specs.md +++ b/web/specs.md @@ -19,7 +19,7 @@ These are CRD/API contracts; no new rollout controls or visual flow are defined. - Fetch and cache data via TanStack Query; invalidate caches on Kubernetes watch events (SSE) - Stream console input/output (WebSocket) and pod/game logs (WebSocket) to the Console and Logs tabs - Wrap the API client in three layers: thin fetch wrapper (`api()`), typed endpoint namespaces (`Servers`, `Templates`, `Cluster`, etc.), and domain helpers (validations, RCON mode resolution, mod/modpack capability detection) -- Present authorized resources from all locations by default, with optional filters; bind each resource operation to its explicit cluster and namespace rather than an ambient global selection +- Present authorized resources from all clusters by default, with optional filters; bind each resource operation to its explicit cluster and namespace rather than an ambient global selection - Enforce role-based access control (RBAC) on frontend routes via `` middleware - Validate form inputs against declared server/template schema before submit - Display real-time resource status (CPU, memory, uptime) and live action metrics from RCON/agent @@ -52,20 +52,20 @@ These are CRD/API contracts; no new rollout controls or visual flow are defined. **Cluster-bound WebSocket:** Server streams carry the open server's explicit cluster and namespace. Reconnects retain that target; leaving the server view closes its streams. Changing an infrastructure selection cannot retarget a stream. Pod logs and PTY attach use the target Kubernetes client; remote agent streams use the configured authenticated gateway and fail closed when unavailable. -## Unified location views +## Unified cluster views The [unified dashboard](../docs/unified-dashboard.md) combines authorized -resources across locations. [Cluster administration](../docs/multicluster-ui.md) +resources across clusters. [Cluster administration](../docs/multicluster-ui.md) retains a separate inventory selection. `design.pen` and the [design exports](../assets/design-export/MANIFEST.md) define the visual surface. -- Dashboard, Servers, Backups and global Search consume `/fleet/*` envelopes. Exact location/namespace filters request narrower backend results so they can recover resources omitted by the combined result limit. Authorized scope metadata keeps filter options available for empty or unavailable locations. -- Each row retains `{cluster, namespace, name, uid}`, resource data and exact target permissions. Same-named resources in different locations remain distinct. Server routes carry `cluster` and `ns`; legacy routes without a cluster resolve local, independently of remembered infrastructure selection. +- Dashboard, Servers, Backups and global Search consume `/fleet/*` envelopes. Exact cluster/namespace filters request narrower backend results so they can recover resources omitted by the combined result limit. Authorized scope metadata keeps filter options available for empty or unavailable clusters. +- Each row retains `{cluster, namespace, name, uid}`, resource data and exact target permissions. Same-named resources in different clusters remain distinct. Server routes carry `cluster` and `ns`; legacy routes without a cluster resolve local, independently of remembered infrastructure selection. - `createRequestClient`, `createResourceClient` and `ResourceTargetProvider` bind JSON requests, raw file/download paths, dialogs, chained mutations and WebSocket URLs to the original scope. Detail waits for matching resource/access identity before mounting actionable children. Instance query keys include UID; the current server-object lookup and namespace collections use stable scope keys so a replacement object can be detected and forms remounted. - Server controls consume `/servers/{name}/access` capabilities and exact named permissions. Owner/collaborator operations remain distinct from full-object writes, owner/admin management, strictly owner-only share links, backup permissions and capture management. The API remains authoritative. - Fleet totals visibly distinguish complete, partial, failed and truncated reads. CPU/memory percentages use summed measured usage divided by matching capacity; missing measurements are unknown. Storage reports provisioned capacity, not actual disk usage. Player totals indicate missing server readings. - Cluster registration and node selection remain in infrastructure administration; ordinary pages have no global cluster switcher. Accounts, roles, modules, audit and installation settings retain central ownership. -- Creation uses eligible placements and templates supplied by `/fleet/placements`. An initial location is captured once, changing location resets the draft, and losing that location does not automatically choose another. All creation follow-ups and success navigation retain the captured target. This adds no cross-cluster scheduler or data migration. +- Creation uses eligible placements and templates supplied by `/fleet/placements`. An initial placement is captured once, changing placement resets the draft, and losing that placement does not automatically choose another. All creation follow-ups and success navigation retain the captured target. This adds no cross-cluster scheduler or data migration. Coverage includes duplicate-name navigation, exact permission boundaries, asynchronous target retention, unavailable and capped responses, weighted metrics, and desktop/mobile filtering. Repository suites run in GitHub Actions. @@ -137,7 +137,7 @@ The multi-slice rebuild is complete: the previous Radix-based primitives that us - **`PageHeader.tsx` (T043)** — route-level page header: thin wrapper around `ui/PageHeader` (slice-0 atom), passing through `title/subtitle/actions/breadcrumbs` unchanged. Called by ~30+ route pages; no changes required in call sites. - **`ClusterSelector.tsx` (T044)** — HeroUI dropdown on the Cluster inventory page, with authorized registrations, phase colors and a **View all clusters** action. It changes the infrastructure selection without setting a default target for server operations. - **`Login.tsx` (T040)** — login form: refactored from raw DOM to HeroUI `TextField`/`Label`/`Input`/`InputGroup`, `Alert` for errors, `Button` for actions. Kept all state, error handling, SSO provider rendering, and marketing panel. Verified compliant with FR-005 (login privacy). -- **`Dashboard.tsx` (T041)** — landing page: renders fleet server/player summaries and inventory statistics, with optional location filtering and partial-coverage indicators. Missing measurements remain unknown. Recent-activity rows for audit entries whose path ends in `/auth/login` read " signed in" for a 2xx status and " failed to sign in" otherwise. +- **`Dashboard.tsx` (T041)** — landing page: renders fleet server/player summaries and inventory statistics, with optional cluster filtering and partial-coverage indicators. Missing measurements remain unknown. Recent-activity rows for audit entries whose path ends in `/auth/login` read " signed in" for a 2xx status and " failed to sign in" otherwise. ### Design Import Rule (FR-012) @@ -828,9 +828,11 @@ package.json # @gameplane/web v0.3.0; dev: vite, npm scripts for - **Step 2 (Container & Ports)**: Configure container image ref (with digest pinning verification badge), custom TCP/UDP ports with advertise flags, and persistent volume size and mount path. - **Step 3 (Review & Export)**: Dual-pane view with syntax-editable YAML manifests (`module.yaml`, `template.yaml`, `README.md`), live offline validation diagnostics, memory scaling simulation (`autoFromMemoryLimit`), and action buttons to download a `.tar.gz` archive or install directly into a cluster upload `ModuleSource`. -7. **Cluster** (`/cluster`) → `ClusterPage` (gated by `cluster:read` permission) - - Selected cluster health and node inventory; **Clusters** (`/clusters`) lists authorized registrations - - Kubeconfig download and node join credentials are local-only and require the corresponding cluster-operation permission and enabled configuration +7. **Clusters** (`/clusters`) → `ClustersPage`; **Nodes** (`/nodes`) → `NodesPage` + - Clusters lists authorized registrations and opens `/clusters/$clusterId` with Overview, Nodes and Configuration tabs. + - Nodes combines authorized inventory by default with URL cluster, text and status filters, sortable columns and responsive stacked rows. Its compact search and Filter popover match Backups: Cluster/Status choices are drafts until Apply, Clear resets drafts, and the badge counts applied filters without search. Cluster details reuse the same inventory with a fixed target and status-only popover. Incomplete coverage and missing metrics remain visible. + - Add node requires an explicit target; node join and kubeconfig download remain local-only and capability/permission gated. Configuration owns kubeconfig, read-only storage, target-bound backup destinations and confirmed remote removal. + - `/cluster` remains a compatibility redirect to Nodes. Navigation and filters never retarget an existing server or pending mutation. 8. **Users** (`/users`) → `UsersPage` (gated by `users:manage` permission) - Create/edit/delete users and OIDC links @@ -838,8 +840,8 @@ package.json # @gameplane/web v0.3.0; dev: vite, npm scripts for 9. **AdminSettings** (`/admin`) → `AdminSettingsPage` (gated by `config:manage` permission) - Sections: General (version), Telemetry (destination, tiers, install ID, preview, status), Authentication (OIDC providers), Mod registries (API keys), - Notification sinks (Discord/Slack/SMTP/webhook), Backup destinations - - **General:** Instance name is optional. It names this install on the Cluster page and in notifications, which fall back to "Gameplane" when it is blank + Notification sinks (Discord/Slack/SMTP/webhook). Backup destinations belong to cluster Configuration; legacy `section=backups` provides a link there. + - **General:** Instance name is optional. It names this install in cluster configuration and in notifications, which fall back to "Gameplane" when it is blank - **Telemetry (spec 022):** the section reads `GET /admin/telemetry` (`Telemetry.get()`, query key `["telemetry"]`, refetched after a save and after an install-ID reset) and the stored consent via `GET /admin/config`. It shows the destination in effect (`default`, `custom`, `bundled`, `disabled` by the operator, `none`) with a link to the data-handling statement (`TELEMETRY_STATEMENT_URL` in `src/lib/links.ts`), two switches ("Enable/Disable telemetry" for basic, "Enable/Disable extended telemetry" for extended), the install ID with a confirmed "Reset ID" (`POST /admin/telemetry/install-id`), a read-only next-report preview identical to the payload, and a fixed-phrase delivery status (never, ok, failed, plus "Install ID replaced on " when `lastIdRotationAt` is set; no failure detail is ever shown). Extended is disabled and shown off while basic is off, and turning basic off clears extended in the draft (the server also normalises it). When the operator disabled telemetry both switches are disabled and shown off and Save is disabled. Saves still go through `useSectionForm` and `PUT /admin/config/telemetry`. - **Telemetry notice (spec 022):** `src/components/ui/TelemetryNotice.tsx` renders the first-login banner at the top of the dashboard body for users with `config:manage` while `GET /admin/telemetry/notice` reports `pending`. It posts `seen` once on first render, offers "Keep sharing", "Turn off extended" and "Turn off all" (each posts the matching ack and hides the banner), and links to Admin Settings → Telemetry and to `TELEMETRY_STATEMENT_URL`. It has no dismiss button. - **Section save semantics:** each config section edits a local draft (`useSectionForm`). Nothing is stored until @@ -861,7 +863,7 @@ package.json # @gameplane/web v0.3.0; dev: vite, npm scripts for - `oidcHelmProvider` — OIDC provider metadata: `groupsClaim` (string), `defaultRole` (string), and `roleMappings` (the **Helm-seeded** role mappings as `{ admin?, operator?, viewer? }`, each role key optionally present) **Split display across two routes (not one):** -1. **Cluster.tsx** displays the **storage class card** — `gameDataStorageClass` value or "Cluster default" badge if unset; rendered with a contextual hint when unset +1. **ClusterConfiguration.tsx** displays the **storage class card** — `gameDataStorageClass` value or "Cluster default" badge if unset; rendered with a contextual hint when unset 2. **AdminSettings.tsx** displays the **OIDC provider and role mappings overrides** — all auth-related configuration Admin settings section drafts follow refreshed configuration while pristine, @@ -877,12 +879,12 @@ values cannot reset that draft. Save requires a successful refresh before any further writes when the last configuration read failed. **Why the split:** -A StorageClass is a cluster infrastructure concern, not an authentication concern. Cluster.tsx is the natural home for infrastructure settings; AdminSettings.tsx owns auth-only config. This separation keeps concerns aligned with where users expect to find them. +A StorageClass is a cluster infrastructure concern, not an authentication concern. ClusterConfiguration.tsx is the natural home for infrastructure settings; AdminSettings.tsx owns auth-only config. This separation keeps concerns aligned with where users expect to find them. **Permission gating:** - **Route guard (web/src/router/tree.tsx):** The `/admin` route itself is guarded by `config:manage` permission (admin and operator roles only). - **AdminSettings.tsx** (OIDC/auth section): The `RoleMappingOverridesCard` component is gated by `config:manage` permission check at render time — since it is an editing surface for role mappings (not read-only display). The route-level and component-level gates are now aligned. -- **Cluster.tsx** (storage card): Is **not** already admin-only, so it requires an explicit `can(me, "config:read")` permission check on both the query (to avoid 403 errors) and the rendered card (to hide it from viewers lacking permission). Precedent: Dashboard.tsx:51 and Users.tsx:688. +- **ClusterConfiguration.tsx** (storage card): Is **not** already admin-only, so it requires an explicit `can(me, "config:read")` permission check on both the query (to avoid 403 errors) and the rendered card (to hide it from viewers lacking permission). Precedent: Dashboard.tsx:51 and Users.tsx:688. **OIDC Role Mapping Overrides (AdminSettings.tsx):** diff --git a/web/src/components/AppLayout.test.tsx b/web/src/components/AppLayout.test.tsx index f780bb6f1..56a17c773 100644 --- a/web/src/components/AppLayout.test.tsx +++ b/web/src/components/AppLayout.test.tsx @@ -123,9 +123,10 @@ describe("AppLayout", () => { expect(nav.getByRole("link", { name: /Servers/i })).toBeInTheDocument(); expect(nav.getByRole("link", { name: /Modules/i })).toBeInTheDocument(); expect(nav.getByRole("link", { name: /Backups/i })).toBeInTheDocument(); - // Server visibility alone does not expose infrastructure administration. - expect(nav.queryByRole("link", { name: "Cluster" })).not.toBeInTheDocument(); - expect(nav.queryByRole("link", { name: "Clusters" })).not.toBeInTheDocument(); + // Discovered registrations remain reachable for configuration roles, + // while node inventory still requires its explicit capability. + expect(nav.queryByRole("link", { name: "Nodes" })).not.toBeInTheDocument(); + expect(nav.getByRole("link", { name: "Clusters" })).toHaveAttribute("href", "/clusters"); expect(nav.queryByRole("link", { name: /Audit log/i })).not.toBeInTheDocument(); }); @@ -136,7 +137,7 @@ describe("AppLayout", () => { ); renderWithQuery(); await waitFor(() => - expect(screen.getByRole("link", { name: /^Cluster$/i })).toBeInTheDocument(), + expect(screen.getByRole("link", { name: "Nodes" })).toHaveAttribute("href", "/nodes"), ); expect(screen.getByRole("link", { name: "Clusters" })).toHaveAttribute("href", "/clusters"); expect(screen.queryByRole("button", { name: "Select cluster" })).not.toBeInTheDocument(); diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx index b86cff967..e1c8516a9 100644 --- a/web/src/components/AppLayout.tsx +++ b/web/src/components/AppLayout.tsx @@ -97,8 +97,8 @@ export function AppLayout() { const installation = useInstallation(); const { data: registry } = useQuery({ queryKey: ["clusters"], queryFn: () => Clusters.list() }); const canViewInventory = registry?.items.some((item) => item.canViewInventory === true) === true; - const canManageInfrastructure = can(me, "cluster:manage") || canViewInventory; - const { pathname } = useLocation(); + const canManageInfrastructure = can(me, "cluster:manage") || (registry?.items.length ?? 0) > 0; + const { pathname, search } = useLocation(); const matches = useMatches(); const navigate = useNavigate(); const [theme, setTheme, isCustomColorsActive] = useAppearance(me); @@ -182,7 +182,7 @@ export function AppLayout() { items: [ ...(canManageInfrastructure ? [{ to: "/clusters", label: intl.formatMessage({ id: "web.components.app_layout.clusters", defaultMessage: "Clusters", description: "Sidebar: navigation link to the clusters page" }), icon: Network }] : []), ...(canViewInventory - ? [{ to: "/cluster", label: intl.formatMessage({ id: "web.components.app_layout.cluster", defaultMessage: "Cluster", description: "Sidebar: navigation link to the cluster page" }), icon: Server }] + ? [{ to: "/nodes", label: intl.formatMessage({ id: "web.components.app_layout.nodes", defaultMessage: "Nodes", description: "Sidebar: navigation link to nodes across all authorized clusters" }), icon: Server }] : []), ...(can(me, "users:manage") ? [{ to: "/users", label: intl.formatMessage({ id: "web.components.app_layout.users_rbac", defaultMessage: "Users & RBAC", description: "Sidebar: navigation link to users and roles" }), icon: Users }] @@ -207,7 +207,7 @@ export function AppLayout() { const onNotFound = matches.some((m) => m.routeId.endsWith("$")); const crumbs = onNotFound ? [{ label: "gameplane", to: "/" }, { label: intl.formatMessage({ id: "web.components.app_layout.page_not_found", defaultMessage: "Page not found", description: "Breadcrumb for the 404 page" }) }] - : buildCrumbs(pathname, intl); + : buildCrumbs(pathname, intl, search?.tab); // Extract the last breadcrumb label as the mobile title const mobileTitle = crumbs.length > 0 ? crumbs[crumbs.length - 1].label : ""; const centralManagement = ["/users", "/admin", "/settings", ...(installation.data?.standalone === false ? ["/modules"] : [])].some( @@ -258,7 +258,7 @@ export function AppLayout() { {can(me, "config:manage") && } {centralManagement && (
- {intl.formatMessage({ id: "web.components.app_layout.central_management_lead", defaultMessage: "Central management.", description: "Bold lead-in of the central-management note shown above page content" })} {intl.formatMessage({ id: "web.components.app_layout.this_catalog_account_or_installation", defaultMessage: "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s location.", description: "App layout: note shown when central management applies; catalog, account or installation settings belong to the central installation, while server actions use each server's location" })}
+ {intl.formatMessage({ id: "web.components.app_layout.central_management_lead", defaultMessage: "Central management.", description: "Bold lead-in of the central-management note shown above page content" })} {intl.formatMessage({ id: "web.components.app_layout.this_catalog_account_or_installation", defaultMessage: "This catalog, account or installation setting belongs to the central Gameplane installation. Server actions use each server’s cluster.", description: "App layout: note shown when central management applies; catalog, account or installation settings belong to the central installation, while server actions use each server's cluster" })} )} diff --git a/web/src/components/CaptureWidget.test.tsx b/web/src/components/CaptureWidget.test.tsx index b753cd47f..1e73922a3 100644 --- a/web/src/components/CaptureWidget.test.tsx +++ b/web/src/components/CaptureWidget.test.tsx @@ -126,7 +126,7 @@ describe("CaptureWidget", () => { expect(enable).not.toHaveBeenCalled(); }); - it("keeps existing files downloadable when the location disables new captures", async () => { + it("keeps existing files downloadable when the cluster disables new captures", async () => { listCompleted(); const requests: URL[] = []; server.use( @@ -151,7 +151,7 @@ describe("CaptureWidget", () => { else delete (URL as { revokeObjectURL?: unknown }).revokeObjectURL; }); renderWithQuery(, ["captures:manage"], target); - expect(await screen.findByText(/New captures are disabled at this location/i)).toBeInTheDocument(); + expect(await screen.findByText(/New captures are disabled at this cluster/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Start Capture/i })).toBeDisabled(); const downloadButton = await screen.findByRole("button", { name: "Download capture cap-remote" }); await waitFor(() => expect(downloadButton).not.toBeDisabled()); diff --git a/web/src/components/CaptureWidget.tsx b/web/src/components/CaptureWidget.tsx index a18f4cbfa..f930a1ffb 100644 --- a/web/src/components/CaptureWidget.tsx +++ b/web/src/components/CaptureWidget.tsx @@ -162,9 +162,9 @@ export function CaptureWidget({ name, ns, gs }: Props) { const canDownload = canRead && capabilityReady && capabilities.data.files; const capabilityMessage = !canManage ? null : capabilities.isPending ? intl.formatMessage({ id: "web.components.capture_widget.checking_capture_support", defaultMessage: "Checking capture support…", description: "Capture widget: status line while capture support loads" }) - : capabilities.isError || capabilities.data?.state === "unavailable" ? intl.formatMessage({ id: "web.components.capture_widget.capture_access_unavailable", defaultMessage: "Capture access is temporarily unavailable at this location.", description: "Capture widget: status line when capture access is unavailable at this location" }) - : capabilities.data?.state === "unsupported" ? intl.formatMessage({ id: "web.components.capture_widget.gateway_update_needed", defaultMessage: "This location needs a gateway update before captures can be managed here.", description: "Capture widget: status line when the location's gateway is too old for captures" }) - : !capabilities.data?.enabled ? intl.formatMessage({ id: "web.components.capture_widget.captures_disabled_at_location", defaultMessage: "New captures are disabled at this location. Existing capture files remain available.", description: "Capture widget: status line when new captures are disabled at this location" }) + : capabilities.isError || capabilities.data?.state === "unavailable" ? intl.formatMessage({ id: "web.components.capture_widget.capture_access_unavailable", defaultMessage: "Capture access is temporarily unavailable at this cluster.", description: "Capture widget: status line when capture access is unavailable at this cluster" }) + : capabilities.data?.state === "unsupported" ? intl.formatMessage({ id: "web.components.capture_widget.gateway_update_needed", defaultMessage: "This cluster needs a gateway update before captures can be managed here.", description: "Capture widget: status line when the cluster's gateway is too old for captures" }) + : !capabilities.data?.enabled ? intl.formatMessage({ id: "web.components.capture_widget.captures_disabled_at_location", defaultMessage: "New captures are disabled at this cluster. Existing capture files remain available.", description: "Capture widget: status line when new captures are disabled at this cluster" }) : null; const { Captures } = resourceClient; const qc = useQueryClient(); diff --git a/web/src/components/FleetScope.tsx b/web/src/components/FleetScope.tsx index 6cb150f60..bb74da82d 100644 --- a/web/src/components/FleetScope.tsx +++ b/web/src/components/FleetScope.tsx @@ -5,10 +5,10 @@ import { useOptionalIntl } from "@/lib/i18n/format"; export function FleetScopeFilter({ value, onChange, clusters }: { value: string; onChange: (value: string) => void; clusters: string[] }) { const intl = useOptionalIntl(); const choices = [...new Set([...clusters, ...(value ? [value] : [])].filter(Boolean))].sort(); - return onChange(String(next ?? ""))} aria-label={intl.formatMessage({ id: "web.components.fleet_scope.filter_by_location", defaultMessage: "Filter by cluster", description: "Fleet scope: screen-reader label for the cluster dropdown that narrows the view to one cluster; not visible on screen" })} className="w-full max-w-xs"> - - {intl.formatMessage({ id: "web.components.fleet_scope.all_locations", defaultMessage: "All locations", description: "Fleet scope: first option in the location dropdown that clears the location filter and shows every location; keep it short" })} + + {intl.formatMessage({ id: "web.components.fleet_scope.all_locations", defaultMessage: "All clusters", description: "Fleet scope: first option in the cluster dropdown that clears the cluster filter and shows every cluster; keep it short" })} {choices.map((cluster) => {cluster})} ; diff --git a/web/src/components/backups/BackupFilters.test.tsx b/web/src/components/backups/BackupFilters.test.tsx index 717fdbd27..46f93162b 100644 --- a/web/src/components/backups/BackupFilters.test.tsx +++ b/web/src/components/backups/BackupFilters.test.tsx @@ -69,18 +69,18 @@ describe("BackupFilters", () => { it("discards a location draft when closed and applies only the reopened selection", async () => { const onLocationChange = vi.fn(); render(); - expect(screen.queryByRole("button", { name: /Filter by location/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); - await userEvent.click(screen.getByRole("button", { name: /Filter by location/ })); + await userEvent.click(screen.getByRole("button", { name: /Filter by cluster/ })); await userEvent.click(screen.getByRole("option", { name: "remote" })); // Dismiss the outer filter menu, not the nested Select trigger. screen.getByRole("button", { name: "Clear" }).focus(); await userEvent.keyboard("{Escape}"); - expect(screen.queryByRole("button", { name: /Filter by location/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); expect(onLocationChange).not.toHaveBeenCalled(); await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); - expect(screen.getByRole("button", { name: /Filter by location/ })).toHaveTextContent("All locations"); - await userEvent.click(screen.getByRole("button", { name: /Filter by location/ })); + expect(screen.getByRole("button", { name: /Filter by cluster/ })).toHaveTextContent("All clusters"); + await userEvent.click(screen.getByRole("button", { name: /Filter by cluster/ })); await userEvent.click(screen.getByRole("option", { name: "remote" })); await userEvent.click(screen.getByRole("button", { name: "Apply" })); expect(onLocationChange).toHaveBeenCalledWith("remote"); diff --git a/web/src/components/backups/BackupFilters.tsx b/web/src/components/backups/BackupFilters.tsx index e7f0be7bf..b59b76a3c 100644 --- a/web/src/components/backups/BackupFilters.tsx +++ b/web/src/components/backups/BackupFilters.tsx @@ -90,7 +90,7 @@ export function BackupFilters({ }} fields={<>
-
{intl.formatMessage({ id: "web.components.backups.backup_filters.location", defaultMessage: "Location", description: "Backup filters: label above the location dropdown in the filter panel" })}
+
{intl.formatMessage({ id: "web.components.backups.backup_filters.location", defaultMessage: "Cluster", description: "Backup filters: label above the cluster dropdown in the filter panel" })}
{ setDraftLocation(next); if (next && servers.find((item) => serverOptionID(item) === draftServer)?.fleetTarget?.cluster !== next) setDraftServer(""); @@ -141,5 +141,5 @@ function serverOptionID(s: GameServer & { fleetTarget?: ResourceTarget }) { } function serverOptionLabel(s: GameServer & { fleetTarget?: ResourceTarget }, intl: IntlShape) { const t = s.fleetTarget; - return t ? intl.formatMessage({ id: "web.components.backups.backup_filters.server_option_label", defaultMessage: "{name} · {cluster} / {namespace}", description: "Backup filters: server option label with its location; {name} is the server name, {cluster} the cluster, {namespace} the namespace" }, { name: s.metadata.name, cluster: t.cluster, namespace: t.namespace }) : s.metadata.name; + return t ? intl.formatMessage({ id: "web.components.backups.backup_filters.server_option_label", defaultMessage: "{name} · {cluster} / {namespace}", description: "Backup filters: server option label with its cluster; {name} is the server name, {cluster} the cluster, {namespace} the namespace" }, { name: s.metadata.name, cluster: t.cluster, namespace: t.namespace }) : s.metadata.name; } diff --git a/web/src/components/clusters/BackupDestinationsPanel.test.tsx b/web/src/components/clusters/BackupDestinationsPanel.test.tsx new file mode 100644 index 000000000..0ca4b8eeb --- /dev/null +++ b/web/src/components/clusters/BackupDestinationsPanel.test.tsx @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { server } from "@/test/server"; +import { renderWithQuery } from "@/test/render"; +import { makeUser } from "@/test/factories"; +import { BackupDestinationsPanel } from "./BackupDestinationsPanel"; + +describe("BackupDestinationsPanel", () => { + it("binds list, create and confirmed deletion to the cluster and namespace", async () => { + const requests: string[] = []; + let exists = false; + server.use(http.all("/backup-destinations", ({ request }) => { + requests.push(request.url); + if (request.method === "POST") exists = true; + return HttpResponse.json({ items: exists ? [{ name: "repo", url: "s3:example/repo", hasPassword: true }] : [] }); + }), http.delete("/backup-destinations/repo", ({ request }) => { + requests.push(request.url); + exists = false; + return new HttpResponse(null, { status: 204 }); + })); + renderWithQuery(); + await userEvent.click(await screen.findByRole("button", { name: "Add destination" })); + await userEvent.type(screen.getByLabelText("Name (DNS label)"), "repo"); + await userEvent.type(screen.getByLabelText("Restic URL"), "s3:example/repo"); + await userEvent.type(screen.getByLabelText("Repository password"), "password"); + await userEvent.click(screen.getByRole("button", { name: "Save destination" })); + await userEvent.click(await screen.findByRole("button", { name: "Delete repo" })); + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + await userEvent.type(screen.getByPlaceholderText("Type to confirm"), "repo"); + await userEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(screen.queryByRole("button", { name: "Delete repo" })).not.toBeInTheDocument()); + expect(requests.length).toBeGreaterThanOrEqual(3); + for (const request of requests) { + const params = new URL(request).searchParams; + expect(params.get("cluster")).toBe("east"); + expect(params.get("namespace")).toBe("gameplane-games"); + } + }); + + it("honors a namespace grant and does not borrow mutation permission from another cluster", async () => { + server.use(http.get("/users/me", () => HttpResponse.json(makeUser({ permissions: {}, permissionsByCluster: { + east: { "gameplane-games": ["destinations:read"] }, west: { "*": ["destinations:manage"] }, + } })))); + renderWithQuery(); + expect(await screen.findByText("default")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add destination" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete default" })).not.toBeInTheDocument(); + }); + + it("does not request destinations without read access to this cluster", async () => { + const reads = vi.fn(() => HttpResponse.json({ items: [] })); + server.use(http.get("/users/me", () => HttpResponse.json(makeUser({ permissions: {}, permissionsByCluster: { + west: { "*": ["destinations:read", "destinations:manage"] }, + } }))), http.get("/backup-destinations", reads)); + renderWithQuery(); + expect(await screen.findByText(/permission to view backup destinations/)).toBeInTheDocument(); + expect(reads).not.toHaveBeenCalled(); + }); + + it("discards password drafts and mutation errors when the route target changes", async () => { + const creates = vi.fn(() => HttpResponse.text("Cannot save", { status: 503 })); + server.use(http.post("/backup-destinations", creates)); + const view = renderWithQuery(); + await userEvent.click(await screen.findByRole("button", { name: "Add destination" })); + await userEvent.type(screen.getByLabelText("Name (DNS label)"), "east-draft"); + await userEvent.type(screen.getByLabelText("Restic URL"), "s3:east/repo"); + await userEvent.type(screen.getByLabelText("Repository password"), "east-password"); + await userEvent.click(screen.getByRole("button", { name: "Save destination" })); + expect(await screen.findByText(/Cannot save/)).toBeInTheDocument(); + view.rerender(); + expect(await screen.findByRole("button", { name: "Add destination" })).toBeInTheDocument(); + expect(screen.queryByDisplayValue("east-password")).not.toBeInTheDocument(); + expect(screen.queryByText(/Cannot save/)).not.toBeInTheDocument(); + expect(creates).toHaveBeenCalledTimes(1); + }); + + it("keeps a pending destination write on its captured target after the route changes", async () => { + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + const requests: string[] = []; + server.use(http.post("/backup-destinations", async ({ request }) => { + requests.push(request.url); + await pending; + return HttpResponse.json({ name: "repo", url: "s3:example/repo", hasPassword: true }); + })); + const view = renderWithQuery(); + await userEvent.click(await screen.findByRole("button", { name: "Add destination" })); + await userEvent.type(screen.getByLabelText("Name (DNS label)"), "repo"); + await userEvent.type(screen.getByLabelText("Restic URL"), "s3:example/repo"); + await userEvent.type(screen.getByLabelText("Repository password"), "east-password"); + await userEvent.click(screen.getByRole("button", { name: "Save destination" })); + await waitFor(() => expect(requests).toHaveLength(1)); + view.rerender(); + expect(await screen.findByRole("button", { name: "Add destination" })).toBeInTheDocument(); + release(); + await waitFor(() => expect(view.client.isMutating()).toBe(0)); + expect(new URL(requests[0]).searchParams.get("cluster")).toBe("east"); + expect(screen.queryByDisplayValue("east-password")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save destination" })).not.toBeInTheDocument(); + }); + + it("keeps failed deletion in its dialog and resets the error on dismissal", async () => { + server.use(http.delete("/backup-destinations/default", () => HttpResponse.text("Deletion failed", { status: 503 }))); + renderWithQuery(); + await userEvent.click(await screen.findByRole("button", { name: "Delete default" })); + await userEvent.type(screen.getByPlaceholderText("Type to confirm"), "default"); + await userEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(await within(screen.getByRole("alertdialog")).findByText(/Deletion failed/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + await userEvent.click(screen.getByRole("button", { name: "Delete default" })); + expect(screen.queryByText(/Deletion failed/)).not.toBeInTheDocument(); + }); + it("lists the cluster backup destinations", async () => { + server.use( + http.get("/admin/config", () => + HttpResponse.json({}), + ), + // BackupDestinations.list() hits /backup-destinations, not the + // /admin-prefixed path — that prefix is only used for the auth + // provider / mod registry secret write endpoints. + http.get("/backup-destinations", () => + HttpResponse.json({ + items: [ + { + name: "primary-backup", + url: "s3:s3.example.com/bucket", + hasPassword: true, + createdAt: "2026-01-01T00:00:00Z", + }, + ], + }), + ), + ); + renderWithQuery(); + expect(await screen.findByText("primary-backup")).toBeInTheDocument(); + }); + + it("shows error when backup destinations fail to load", async () => { + server.use( + http.get("/admin/config", () => + HttpResponse.json({}), + ), + http.get("/backup-destinations", () => + new HttpResponse("Server error", { status: 500 }), + ), + ); + renderWithQuery(); + expect(await screen.findByText(/error/i)).toBeInTheDocument(); + }); + + it("shows empty state for backup destinations", async () => { + server.use( + http.get("/admin/config", () => + HttpResponse.json({}), + ), + http.get("/backup-destinations", () => + HttpResponse.json({ items: [] }), + ), + ); + renderWithQuery(); + expect( + await screen.findByText(/No backup destinations configured/i), + ).toBeInTheDocument(); + }); + + it("lists backup destinations and opens the add form", async () => { + renderWithQuery(); + // The default handler returns one destination ("default"). + await userEvent.click(await screen.findByRole("button", { name: /Add destination/i })); + const nameField = await screen.findByPlaceholderText("gameplane-backup-repo"); + await userEvent.type(nameField, "repo1"); + await userEvent.type(screen.getByPlaceholderText(/s3:s3.example.com/i), "s3:host/bucket"); + await userEvent.type(screen.getByPlaceholderText(/passphrase/i), "a-strong-passphrase"); + const save = screen.getByRole("button", { name: /Save destination/i }); + await waitFor(() => expect(save).toBeEnabled()); + await userEvent.click(save); + // On success the form closes; the Add button returns. + expect(await screen.findByRole("button", { name: /Add destination/i })).toBeInTheDocument(); + }); + + it("prompts before deleting a backup destination", async () => { + renderWithQuery(); + await userEvent.click(await screen.findByRole("button", { name: /Delete default/i })); + expect(await screen.findByText(/Delete backup destination\?/i)).toBeInTheDocument(); + }); + + +}); diff --git a/web/src/components/clusters/BackupDestinationsPanel.tsx b/web/src/components/clusters/BackupDestinationsPanel.tsx new file mode 100644 index 000000000..871c60415 --- /dev/null +++ b/web/src/components/clusters/BackupDestinationsPanel.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button, Input as HeroInput } from "@heroui/react"; +import { Archive, Plus, Trash2 } from "lucide-react"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; +import { ErrorCard } from "@/components/ui/ErrorCard"; +import { LoadingCard } from "@/components/ui/LoadingCard"; +import { ErrorBanner } from "@/components/backups/ErrorBanner"; +import { createResourceClient } from "@/lib/endpoints"; +import { APIError } from "@/lib/api"; +import { useMe, can } from "@/lib/auth"; +import { formatRelative } from "@/lib/utils"; +import { useOptionalIntl } from "@/lib/i18n/format"; +import { ClusterSection, ClusterField } from "./ClusterSection"; + +// Existing destination requests use the API's default workload namespace. +const DESTINATION_NAMESPACE = "gameplane-games"; + +export function BackupDestinationsPanel({ clusterId }: { clusterId: string }) { + const intl = useOptionalIntl(); + const identity = useMe(); + if (identity.isPending) return ; + if (identity.isError && (!identity.data || (identity.error instanceof APIError && identity.error.status === 401))) return void identity.refetch()} />; + if (!can(identity.data, "destinations:read", DESTINATION_NAMESPACE, clusterId)) return

{intl.formatMessage({ id: "web.cluster_configuration.destinations_denied", defaultMessage: "You don't have permission to view backup destinations on this cluster.", description: "Cluster configuration: backup destination access denied" })}

; + return <>{identity.isError && void identity.refetch()} />}; +} + +function ClusterBackupDestSection({ cluster, canManage }: { cluster: string; canManage: boolean }) { + const intl = useOptionalIntl(); + const qc = useQueryClient(); + const { BackupDestinations } = createResourceClient({ cluster, namespace: DESTINATION_NAMESPACE }); + const [adding, setAdding] = useState(false); + const [deleting, setDeleting] = useState(null); + + const list = useQuery({ + queryKey: ["backup-destinations", cluster, DESTINATION_NAMESPACE], + queryFn: ({ signal }) => createResourceClient({ cluster, namespace: DESTINATION_NAMESPACE }, signal).BackupDestinations.list(), + }); + const remove = useMutation({ + mutationFn: (name: string) => BackupDestinations.remove(name), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["backup-destinations", cluster, DESTINATION_NAMESPACE] }); + setDeleting(null); + }, + }); + + const items = list.data?.items ?? []; + + return ( + setAdding(true)} isDisabled={adding}> + {intl.formatMessage({ id: "web.routes.admin_settings.add_destination", defaultMessage: "Add destination", description: "Admin settings: button that opens the form to add a backup destination, and the heading of that form; keep it short" })} + } + > + {list.isLoading &&
{intl.formatMessage({ id: "web.routes.admin_settings.loading_destinations", defaultMessage: "Loading destinations…", description: "Admin settings: loading text in the backup destinations card while the list is fetched" })}
} + {list.isError && } + {!list.isLoading && !list.isError && items.length === 0 && !adding && ( +
{intl.formatMessage({ id: "web.routes.admin_settings.no_backup_destinations_configured_add", defaultMessage: "No backup destinations configured. Add one to enable snapshots.", description: "Admin settings: empty-state text in the backup destinations card when none exist, asking the user to add one to enable snapshots" })}
+ )} + {adding && canManage && setAdding(false)} />} +
    + {items.map((d) => ( +
  • +
    + +
    +
    +
    {d.name}
    +
    {d.url}
    +
    + + {d.hasPassword ? intl.formatMessage({ id: "web.routes.admin_settings.configured_lower", defaultMessage: "configured", description: "Admin settings: badge on a backup destination whose password is stored" }) : intl.formatMessage({ id: "web.routes.admin_settings.no_password", defaultMessage: "no-password", description: "Admin settings: badge on a backup destination with no stored password" })} + + {d.createdAt && ( + {formatRelative(d.createdAt, intl)} + )} + {canManage && } +
  • + ))} +
+ { if (!open && !remove.isPending) { setDeleting(null); remove.reset(); } }} + title={intl.formatMessage({ id: "web.routes.admin_settings.delete_backup_destination", defaultMessage: "Delete backup destination?", description: "Admin settings: title of the confirmation dialog that asks whether to delete a backup destination" })} + description={ + <> + {remove.error && } +

{intl.formatMessage({ id: "web.routes.admin_settings.the_repository_at_this_destination_will", defaultMessage: "The repository at this destination will", description: "Admin settings: opening words of the delete dialog text, followed by the emphasised word not and the rest of the sentence" })} {intl.formatMessage({ id: "web.routes.admin_settings.not", defaultMessage: "not", description: "Admin settings: the word not, shown in bold in the delete dialog text to stress the repository is not erased" })} {intl.formatMessage({ id: "web.routes.admin_settings.be_erased_only_the_credentials", defaultMessage: "be erased — only the credentials Gameplane uses to reach it. Existing backups remain intact; new backups against this destination will fail until it's re-added.", description: "Admin settings: end of the delete dialog text: only Gameplane's credentials for the destination are removed, existing backups stay and new backups fail" })}

+ {deleting && ( +

{intl.formatMessage({ id: "web.routes.admin_settings.type", defaultMessage: "Type", description: "Admin settings: first word of the line in the delete dialog asking the user to type the destination name to confirm" })} {deleting} {intl.formatMessage({ id: "web.routes.admin_settings.to_confirm", defaultMessage: "to confirm.", description: "Admin settings: last words of the line asking the user to type the destination name to confirm; the name is shown in monospace before them" })}

+ )} + + } + confirmPhrase={deleting ?? undefined} + confirmLabel={intl.formatMessage({ id: "web.routes.admin_settings.delete", defaultMessage: "Delete", description: "Admin settings: confirm button of the delete backup destination dialog; keep it short" })} + destructive + busy={remove.isPending} + onConfirm={() => canManage && deleting && remove.mutate(deleting)} + /> +
+ ); +} + +function NewDestinationForm({ cluster, onClose }: { cluster: string; onClose: () => void }) { + const intl = useOptionalIntl(); + const qc = useQueryClient(); + const { BackupDestinations } = createResourceClient({ cluster, namespace: DESTINATION_NAMESPACE }); + const [form, setForm] = useState({ name: "", url: "", password: "" }); + const create = useMutation({ + mutationFn: () => BackupDestinations.upsert(form), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["backup-destinations", cluster, DESTINATION_NAMESPACE] }); + onClose(); + }, + }); + const valid = + /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/.test(form.name) && + form.url.length > 0 && + form.password.length > 0; + return ( +
+
{intl.formatMessage({ id: "web.routes.admin_settings.add_destination", defaultMessage: "Add destination", description: "Admin settings: button that opens the form to add a backup destination, and the heading of that form; keep it short" })}
+

{intl.formatMessage({ id: "web.routes.admin_settings.restic_url_hint", defaultMessage: "Stored as a Secret labelled {secret}. Restic URL formats: {s3}, {b2}, {azure}, etc.", description: "Admin settings: hint under the backup destination form; {secret} is the Secret label, {s3}, {b2} and {azure} are example Restic URL formats" }, { secret: {intl.formatMessage({ id: "web.routes.admin_settings.gameplane_local_backup_destination_true", defaultMessage: "gameplane.local/backup-destination=true", description: "Admin settings: the Secret label example in the backup destination hint" })}, s3: {intl.formatMessage({ id: "web.routes.admin_settings.s3_host_bucket", defaultMessage: "s3:host/bucket", description: "Admin settings: example Restic URL format for S3" })}, b2: {intl.formatMessage({ id: "web.routes.admin_settings.b2_bucket", defaultMessage: "b2:bucket", description: "Admin settings: example Restic URL format for Backblaze B2" })}, azure: {intl.formatMessage({ id: "web.routes.admin_settings.azure_account_container", defaultMessage: "azure:account/container", description: "Admin settings: example Restic URL format for Azure" })} })}

+
+ + setForm({ ...form, name: e.target.value })} + /> + + + setForm({ ...form, url: e.target.value })} + /> + + + setForm({ ...form, password: e.target.value })} + /> + +
+ {create.error && } +
+ + +
+
+ ); +} + diff --git a/web/src/components/clusters/ClusterConfiguration.test.tsx b/web/src/components/clusters/ClusterConfiguration.test.tsx new file mode 100644 index 000000000..1c5fe4fc7 --- /dev/null +++ b/web/src/components/clusters/ClusterConfiguration.test.tsx @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { server } from "@/test/server"; +import { renderWithQuery } from "@/test/render"; +import { makeUser } from "@/test/factories"; +import type { ClusterRegistry } from "@/types"; +import { getCurrentCluster, setCurrentCluster } from "@/lib/cluster"; +const navigate = vi.hoisted(() => vi.fn()); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigate })); +import { ClusterConfiguration } from "./ClusterConfiguration"; +const local: ClusterRegistry = { name: "local", displayName: "Local", phase: "Healthy", canViewInventory: true }; +function ClusterPage() { return ; } +afterEach(() => { vi.restoreAllMocks(); navigate.mockReset(); setCurrentCluster("local"); }); +describe("ClusterConfiguration", () => { + it.each([false, true, undefined])("honors clusterOps=%s while retaining compatibility with an unavailable info endpoint", async (clusterOps) => { + server.use(http.get("/cluster/info", () => clusterOps === undefined ? HttpResponse.error() : HttpResponse.json({ clusterOps }))); + const { client } = renderWithQuery(); + await waitFor(() => expect(client.getQueryState(["cluster-info", "local"])?.status).toBe(clusterOps === undefined ? "error" : "success")); + const button = await screen.findByRole("button", { name: /download kubeconfig/i }); + await waitFor(() => { + if (clusterOps === false) expect(button).toBeDisabled(); else expect(button).toBeEnabled(); + }); + }); + + it("shows the local operation-disabled error returned by the API", async () => { + server.use(http.post("/cluster/kubeconfig", () => HttpResponse.text("disabled", { status: 501 }))); + renderWithQuery(); + const button = await screen.findByRole("button", { name: /download kubeconfig/i }); + await waitFor(() => expect(button).toBeEnabled()); + await userEvent.click(button); + expect(await screen.findByText(/aren't enabled/)).toBeInTheDocument(); + }); + + it("does not expose local configuration or downloadable credentials on a remote", async () => { + const config = vi.fn(() => HttpResponse.json({ installTimeSettings: { gameDataStorageClass: "local-only" } })); + server.use(http.get("/admin/config", config)); + renderWithQuery(); + expect(await screen.findByRole("button", { name: /download kubeconfig/i })).toBeDisabled(); + expect(screen.getByText(/managed at this remote cluster/)).toBeInTheDocument(); + expect(screen.queryByText("Game data storage class")).not.toBeInTheDocument(); + expect(config).not.toHaveBeenCalled(); + }); + + it("does not mint credentials for a viewer", async () => { + server.use(http.get("/users/me", () => HttpResponse.json(makeUser({ role: "viewer" })))); + renderWithQuery(); + expect(await screen.findByRole("button", { name: /download kubeconfig/i })).toBeDisabled(); + }); + + it("discards a late local download after navigating to a remote cluster", async () => { + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + let requested = false; + server.use(http.post("/cluster/kubeconfig", async () => { requested = true; await pending; return HttpResponse.text("local-secret"); })); + const blob = vi.fn(() => "blob:test"); + globalThis.URL.createObjectURL = blob; + const view = renderWithQuery(); + const button = await screen.findByRole("button", { name: /download kubeconfig/i }); + await waitFor(() => expect(button).toBeEnabled()); + await userEvent.click(button); + await waitFor(() => expect(requested).toBe(true)); + view.rerender(); + await act(async () => { release(); await pending; }); + await waitFor(() => expect(view.client.isMutating()).toBe(0)); + expect(blob).not.toHaveBeenCalled(); + }); + + it("requires confirmation, retains removal errors in the dialog, and clears only the removed selection", async () => { + setCurrentCluster("east"); + let fails = true; + const remove = vi.fn(() => fails ? HttpResponse.text("failed", { status: 503 }) : new HttpResponse(null, { status: 204 })); + server.use(http.delete("/clusters/east", remove)); + renderWithQuery(); + await userEvent.click(await screen.findByRole("button", { name: "Remove cluster East" })); + expect(remove).not.toHaveBeenCalled(); + expect(within(screen.getByRole("alertdialog")).getByText(/Running workloads remain/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Remove cluster" })); + const dialog = screen.getByRole("alertdialog"); + expect(await within(dialog).findByRole("alert")).toHaveTextContent("Couldn't remove the cluster registration."); + expect(getCurrentCluster()).toBe("east"); + await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await userEvent.click(screen.getByRole("button", { name: "Remove cluster East" })); + expect(within(screen.getByRole("alertdialog")).queryByRole("alert")).not.toBeInTheDocument(); + fails = false; + await userEvent.click(screen.getByRole("button", { name: "Remove cluster" })); + await waitFor(() => expect(getCurrentCluster()).toBe("")); + expect(localStorage.getItem("gameplane.cluster")).toBe(""); + expect(navigate).toHaveBeenCalledWith({ to: "/clusters" }); + }); + + it("keeps another cluster selected after removing a registration", async () => { + setCurrentCluster("west"); + server.use(http.delete("/clusters/east", () => new HttpResponse(null, { status: 204 }))); + renderWithQuery(); + await userEvent.click(await screen.findByRole("button", { name: "Remove cluster East" })); + await userEvent.click(screen.getByRole("button", { name: "Remove cluster" })); + await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: "/clusters" })); + expect(getCurrentCluster()).toBe("west"); + }); + it("downloads kubeconfig blob", async () => { + const createObjectURLMock = vi.fn(() => "blob:mock-url"); + const revokeObjectURLMock = vi.fn(); + const clickMock = vi.fn(); + + globalThis.URL.createObjectURL = createObjectURLMock; + globalThis.URL.revokeObjectURL = revokeObjectURLMock; + + server.use( + http.post("/cluster/kubeconfig", () => + // A raw `Blob` body throws inside msw's Response construction on this + // environment's jsdom (its Blob lacks `.stream()`, which msw's + // Response proxy calls while building the mocked Response) — use a + // string body instead. The client's `res.blob()` still round-trips it + // into a real Blob regardless of the original body shape (same fix + // as AuditLog.test.tsx's CSV export test). + new HttpResponse("kubeconfig content", { headers: { "Content-Type": "text/plain" } }) + ), + ); + + renderWithQuery(); + const downloadBtn = await screen.findByRole("button", { name: /download kubeconfig/i }); + await waitFor(() => expect(downloadBtn).toBeEnabled()); + + // Mock createElement and click + const originalCreateElement = document.createElement.bind(document); + document.createElement = vi.fn((tag) => { + if (tag === "a") { + const el = originalCreateElement("a"); + el.click = clickMock; + return el; + } + return originalCreateElement(tag); + }); + + await userEvent.click(downloadBtn); + await waitFor(() => { + expect(clickMock).toHaveBeenCalled(); + expect(createObjectURLMock).toHaveBeenCalledWith(expect.any(Blob)); + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock-url"); + }); + + // Cleanup + document.createElement = originalCreateElement; + }); + + it("shows error when kubeconfig download fails", async () => { + server.use( + http.post("/cluster/kubeconfig", () => + new HttpResponse("forbidden", { status: 403 }) + ), + ); + renderWithQuery(); + const downloadBtn = await screen.findByRole("button", { name: /download kubeconfig/i }); + await waitFor(() => expect(downloadBtn).toBeEnabled()); + await userEvent.click(downloadBtn); + expect(await screen.findByText(/don't have permission/i, {}, { timeout: 10000 })).toBeInTheDocument(); + }); + + it("shows generic error when kubeconfig download fails with 500", async () => { + server.use( + http.post("/cluster/kubeconfig", () => + new HttpResponse("internal error", { status: 500 }) + ), + ); + renderWithQuery(); + const downloadBtn = await screen.findByRole("button", { name: /download kubeconfig/i }); + await waitFor(() => expect(downloadBtn).toBeEnabled()); + await userEvent.click(downloadBtn); + expect(await screen.findByText(/operation failed/i)).toBeInTheDocument(); + }); + + describe("Storage section", () => { + it("renders the storage card when installTimeSettings is present with a configured class", async () => { + server.use( + http.get("/admin/config", () => + HttpResponse.json({ + general: { + instanceName: "Gameplane", + externalURL: "https://gameplane.local", + defaultNamespace: "gameplane-games", + }, + installTimeSettings: { + gameDataStorageClass: "fast-nvme", + }, + }), + ), + ); + renderWithQuery(); + // Wait for the page to render and the config to load. + expect(await screen.findByText("Storage")).toBeInTheDocument(); + expect(screen.getByText("Game data storage class")).toBeInTheDocument(); + expect(screen.getByText("fast-nvme")).toBeInTheDocument(); + expect(screen.getByText(/StorageClass for new GameServers/)).toBeInTheDocument(); + }); + + it("renders the storage card with 'Cluster default' when gameDataStorageClass is unset", async () => { + server.use( + http.get("/admin/config", () => + HttpResponse.json({ + general: { + instanceName: "Gameplane", + externalURL: "https://gameplane.local", + defaultNamespace: "gameplane-games", + }, + installTimeSettings: { + gameDataStorageClass: "", + }, + }), + ), + ); + renderWithQuery(); + expect(await screen.findByText("Storage")).toBeInTheDocument(); + expect(screen.getByText("Game data storage class")).toBeInTheDocument(); + // Should render the "Cluster default" pill badge. + expect(screen.getByText("Cluster default")).toBeInTheDocument(); + // Should NOT render the storage class as plain text. + expect(screen.queryByText(/^fast-nvme$/)).not.toBeInTheDocument(); + // Should include the additional hint text about using cluster default. + expect(screen.getByText(/Left unset, so new volumes use the cluster's default StorageClass/)).toBeInTheDocument(); + }); + + it("does not render the storage card when installTimeSettings is absent", async () => { + server.use( + http.get("/admin/config", () => + HttpResponse.json({ + general: { + instanceName: "Gameplane", + externalURL: "https://gameplane.local", + defaultNamespace: "gameplane-games", + }, + }), + ), + ); + renderWithQuery(); + // The page should still render (with node data). + await screen.findByRole("button", { name: /download kubeconfig/i }); + // But the storage section should not be present. + expect(screen.queryByText("Storage")).not.toBeInTheDocument(); + expect(screen.queryByText("Game data storage class")).not.toBeInTheDocument(); + }); + + it("does not render storage card and does not fire admin-config query for users without config:read permission", async () => { + const configHandler = vi.fn(() => + HttpResponse.json({ + general: { + instanceName: "Gameplane", + externalURL: "https://gameplane.local", + defaultNamespace: "gameplane-games", + }, + installTimeSettings: { + gameDataStorageClass: "fast-nvme", + }, + }), + ); + server.use( + http.get("/users/me", () => HttpResponse.json(makeUser({ role: "viewer" }))), + http.get("/admin/config", configHandler), + ); + const { client } = renderWithQuery(); + // Wait for the viewer identity to load. + await waitFor(() => expect(client.getQueryData(["me"])).toBeTruthy()); + // Wait for the page to render (the cluster data should load). + await screen.findByRole("button", { name: /download kubeconfig/i }); + // The storage section should NOT be present. + expect(screen.queryByText("Storage")).not.toBeInTheDocument(); + expect(screen.queryByText("Game data storage class")).not.toBeInTheDocument(); + // The /admin/config handler should NOT have been called. + expect(configHandler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/components/clusters/ClusterConfiguration.tsx b/web/src/components/clusters/ClusterConfiguration.tsx new file mode 100644 index 000000000..96afd30bd --- /dev/null +++ b/web/src/components/clusters/ClusterConfiguration.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { Button } from "@heroui/react"; +import { Download } from "lucide-react"; +import { api, APIError } from "@/lib/api"; +import { Cluster, Clusters } from "@/lib/endpoints"; +import { can, useMe } from "@/lib/auth"; +import { getCurrentCluster, setCurrentCluster } from "@/lib/cluster"; +import { useInstallation } from "@/lib/useInstallation"; +import { useOptionalIntl } from "@/lib/i18n/format"; +import type { AllConfig } from "@/lib/config"; +import type { ClusterRegistry } from "@/types"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; +import { ErrorCard } from "@/components/ui/ErrorCard"; +import { LoadingCard } from "@/components/ui/LoadingCard"; +import { BackupDestinationsPanel } from "./BackupDestinationsPanel"; +import { ClusterSection } from "./ClusterSection"; + +const CLUSTER_OPS_KEY = "clusterOps.enabled"; + +export function ClusterConfiguration({ cluster }: { cluster: ClusterRegistry }) { + return ; +} + +function ConfigurationTarget({ cluster }: { cluster: ClusterRegistry }) { + const intl = useOptionalIntl(); + const identity = useMe(); + const installation = useInstallation(); + const client = useQueryClient(); + const navigate = useNavigate(); + const active = useRef(true); + useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); + const [removeOpen, setRemoveOpen] = useState(false); + const isLocal = cluster.name === "local" && installation.data?.localCluster === true; + const canManage = can(identity.data, "cluster:manage"); + const canReadConfig = isLocal && can(identity.data, "config:read"); + const info = useQuery({ queryKey: ["cluster-info", cluster.name], queryFn: ({ signal }) => Cluster.info(cluster.name, signal), enabled: isLocal && cluster.canViewInventory === true }); + const config = useQuery({ queryKey: ["config"], queryFn: ({ signal }) => api("/admin/config", { signal }), enabled: canReadConfig }); + // Older APIs omit clusterOps. Preserve server-side 501 as the backstop. + const opsDisabled = !isLocal || !canManage || info.isLoading || info.data?.clusterOps === false; + const download = useMutation({ + mutationFn: () => Cluster.kubeconfig(cluster.name), + onSuccess: (blob) => { + if (!active.current) return; + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + try { + anchor.href = url; + anchor.download = "gameplane-kubeconfig.yaml"; + document.body.appendChild(anchor); + anchor.click(); + } finally { + anchor.remove(); + URL.revokeObjectURL(url); + } + }, + }); + const remove = useMutation({ + mutationFn: () => Clusters.remove(cluster.name), + onSuccess: async () => { + if (getCurrentCluster() === cluster.name) setCurrentCluster(""); + await client.invalidateQueries({ queryKey: ["clusters"] }); + await client.invalidateQueries({ queryKey: ["fleet"] }); + if (active.current) { setRemoveOpen(false); await navigate({ to: "/clusters" }); } + }, + }); + + if (installation.isPending || identity.isPending) return ; + const installationFailure = void installation.refetch()} />; + if (installation.isError && (!installation.data || (installation.error instanceof APIError && installation.error.status === 401))) return installationFailure; + if (identity.isError && (!identity.data || (identity.error instanceof APIError && identity.error.status === 401))) return void identity.refetch()} />; + + const errorText = download.error instanceof APIError && download.error.status === 501 + ? intl.formatMessage({ id: "web.routes.cluster.op_not_enabled", defaultMessage: "Cluster operations aren't enabled on this install (set clusterOps.enabled).", description: "Cluster: error shown when the operator has cluster operations turned off (HTTP 501)" }) + : download.error instanceof APIError && download.error.status === 403 + ? intl.formatMessage({ id: "web.routes.cluster.op_forbidden", defaultMessage: "You don't have permission to do that.", description: "Cluster: error shown when the user lacks permission for a cluster operation (HTTP 403)" }) + : intl.formatMessage({ id: "web.routes.cluster.op_failed", defaultMessage: "That operation failed. Check the API logs.", description: "Cluster: generic error shown when a cluster operation fails" }); + + return
+ {installation.isError && installationFailure} + + + {!isLocal ?

{intl.formatMessage({ id: "web.cluster_configuration.remote_access", defaultMessage: "Kubeconfig downloads and install-time storage settings are managed at this remote cluster.", description: "Cluster configuration: unsupported remote access and storage guidance" })}

+ : !canManage ?

{intl.formatMessage({ id: "web.cluster_configuration.access_permission", defaultMessage: "Cluster management permission is required to download a kubeconfig.", description: "Cluster access permission hint" })}

+ : info.data?.clusterOps === false &&

{intl.formatMessage({ id: "web.cluster_configuration.enable_ops", defaultMessage: "Enable {setting} in the Helm values to download kubeconfigs.", description: "Disabled local kubeconfig capability hint" }, { setting: {CLUSTER_OPS_KEY} })}

} + {download.error &&

{errorText}

} +
+ {canReadConfig && config.isPending && } + {canReadConfig && config.isError && void config.refetch()} />} + {canReadConfig && config.data?.installTimeSettings && } + + {cluster.name !== "local" && canManage && + + } + { if (!open && !remove.isPending) { setRemoveOpen(false); remove.reset(); } }} title={intl.formatMessage({ id: "web.routes.clusters.remove_title", defaultMessage: "Remove {name}?", description: "Clusters: title of the remove-cluster confirmation; {name} is the cluster name" }, { name: cluster.displayName || cluster.name })} + description={

{intl.formatMessage({ id: "web.routes.clusters.removes_this_panel_s_connection_to_the", defaultMessage: "Removes this panel's connection to the cluster. Running workloads remain on the cluster.", description: "Clusters: text in the remove-cluster confirmation dialog saying the panel's connection is removed while running workloads stay on the cluster" })}

{remove.error &&

{intl.formatMessage({ id: "web.routes.clusters.couldn_t_remove_the_cluster", defaultMessage: "Couldn't remove the cluster registration. Try again.", description: "Clusters: error message in the remove-cluster dialog shown when removing the registration fails; the user can try again" })}

}
} + confirmLabel={intl.formatMessage({ id: "web.routes.clusters.remove_cluster", defaultMessage: "Remove cluster", description: "Clusters: confirm button in the remove-cluster dialog, shown in a destructive style; keep it short" })} destructive busy={remove.isPending} onConfirm={() => cluster.name !== "local" && canManage && remove.mutate()} /> +
; +} + +function StorageSection({ settings }: { settings: NonNullable }) { + const intl = useOptionalIntl(); + return +
{intl.formatMessage({ id: "web.routes.cluster.game_data_storage_class", defaultMessage: "Game data storage class", description: "Cluster: label of the setting that names the storage class used for new game server data volumes; storage class is a technical term, usually left untranslated" })}

{intl.formatMessage({ id: "web.routes.cluster.storageclass_for_new_gameservers_game", defaultMessage: "StorageClass for new GameServers' game-data volumes — set by the operator.gameDataStorage.storageClassName Helm value and applied by the operator to every new PVC; the API is passed the same value as --game-data-storage-class only to report it here.", description: "Cluster: explanation under the game data storage class label; a Helm value sets it and it applies to new volumes; StorageClass is a technical term, usually left untranslated" })} {settings.gameDataStorageClass === "" && intl.formatMessage({ id: "web.routes.cluster.storage_class_unset", defaultMessage: "Left unset, so new volumes use the cluster's default StorageClass.", description: "Cluster: note under the StorageClass description when no StorageClass is set" })}

{settings.gameDataStorageClass || intl.formatMessage({ id: "web.routes.cluster.cluster_default", defaultMessage: "Cluster default", description: "Cluster: badge shown instead of a storage class name when none is set, meaning new volumes use the cluster's default; keep it short" })}
+
; +} diff --git a/web/src/components/clusters/ClusterSection.tsx b/web/src/components/clusters/ClusterSection.tsx new file mode 100644 index 000000000..297fb3cd5 --- /dev/null +++ b/web/src/components/clusters/ClusterSection.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from "react"; +import { Card, TextField, Label, Description } from "@heroui/react"; +import { cn } from "@/lib/utils"; + +export function ClusterSection({ + title, + subtitle, + footer, + children, + className, + headerClassName, + titleClassName, + subtitleClassName, + contentClassName, + footerClassName, +}: { + title: string; + subtitle?: string; + footer?: ReactNode; + children: ReactNode; + className?: string; + headerClassName?: string; + titleClassName?: string; + subtitleClassName?: string; + contentClassName?: string; + footerClassName?: string; +}) { + return ( + + +
{title}
+ {subtitle &&
{subtitle}
} +
+ {children} + {footer && ( + + {footer} + + )} +
+ ); +} + +export function ClusterField({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) { + return ( + + + {children} + {hint && {hint}} + + ); +} + diff --git a/web/src/components/clusters/LegacyBackupSettings.test.tsx b/web/src/components/clusters/LegacyBackupSettings.test.tsx new file mode 100644 index 000000000..2a07a164f --- /dev/null +++ b/web/src/components/clusters/LegacyBackupSettings.test.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { screen } from "@testing-library/react"; +import { server } from "@/test/server"; +import { renderWithQuery } from "@/test/render"; +import { setCurrentCluster } from "@/lib/cluster"; +vi.mock("@tanstack/react-router", () => ({ Link: ({ children, to, params, search }: { children: ReactNode; to: string; params?: { clusterId: string }; search?: { tab: string } }) => {children} })); +import { LegacyBackupSettings } from "./LegacyBackupSettings"; + +afterEach(() => setCurrentCluster("local")); +describe("LegacyBackupSettings", () => { + it("preserves the local bookmark destination on combined installations despite a remembered remote", async () => { + setCurrentCluster("east"); + const reads = vi.fn(() => HttpResponse.json({ items: [] })); + server.use(http.get("/clusters", () => HttpResponse.json({ items: [{ name: "local" }, { name: "east", displayName: "East" }] })), http.get("/backup-destinations", reads)); + renderWithQuery(); + expect(await screen.findByRole("link", { name: "Open local configuration" })).toHaveAttribute("href", "/clusters/local?tab=configuration"); + expect(reads).not.toHaveBeenCalled(); + }); + + it("uses a valid remembered remote on standalone installations", async () => { + setCurrentCluster("east"); + const reads = vi.fn(() => HttpResponse.json({ items: [] })); + server.use(http.get("/admin/installation", () => HttpResponse.json({ standalone: true, localCluster: false })), http.get("/clusters", () => HttpResponse.json({ items: [{ name: "east", displayName: "East" }] })), http.get("/backup-destinations", reads)); + renderWithQuery(); + expect(await screen.findByRole("link", { name: "Open East configuration" })).toHaveAttribute("href", "/clusters/east?tab=configuration"); + expect(reads).not.toHaveBeenCalled(); + }); + + it("does not substitute a remembered remote when local is unavailable on a combined installation", async () => { + setCurrentCluster("east"); + server.use(http.get("/clusters", () => HttpResponse.json({ items: [{ name: "east", displayName: "East" }] }))); + renderWithQuery(); + expect(await screen.findByRole("link", { name: "Choose a cluster" })).toHaveAttribute("href", "/clusters"); + }); + + it.each(["", "local", "deleted"])("requests no workload for invalid standalone selection %s", async (selection) => { + setCurrentCluster(selection); + const reads = vi.fn(() => HttpResponse.json({ items: [] })); + server.use(http.get("/admin/installation", () => HttpResponse.json({ standalone: true, localCluster: false })), http.get("/clusters", () => HttpResponse.json({ items: [] })), http.get("/backup-destinations", reads)); + renderWithQuery(); + expect(await screen.findByRole("link", { name: "Choose a cluster" })).toHaveAttribute("href", "/clusters"); + expect(reads).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Add destination" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/clusters/LegacyBackupSettings.tsx b/web/src/components/clusters/LegacyBackupSettings.tsx new file mode 100644 index 000000000..7e1352787 --- /dev/null +++ b/web/src/components/clusters/LegacyBackupSettings.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { Clusters } from "@/lib/endpoints"; +import { useCurrentCluster } from "@/lib/cluster"; +import { useInstallation } from "@/lib/useInstallation"; +import { useOptionalIntl } from "@/lib/i18n/format"; +import { ErrorCard } from "@/components/ui/ErrorCard"; +import { LoadingCard } from "@/components/ui/LoadingCard"; +import { ClusterSection } from "./ClusterSection"; + +/** Preserve saved /admin?section=backups URLs without issuing workload requests. */ +export function LegacyBackupSettings() { + const intl = useOptionalIntl(); + const selected = useCurrentCluster(); + const installation = useInstallation(); + const registry = useQuery({ queryKey: ["clusters"], queryFn: () => Clusters.list() }); + if (installation.isPending || registry.isPending) return ; + if (installation.isError) return void installation.refetch()} />; + if (registry.isError) return void registry.refetch()} />; + // The legacy combined-installation screen always owned local destinations. + // Only standalone installations used the remembered workload selection. + const targetId = !installation.data.standalone && installation.data.localCluster + ? "local" + : installation.data.standalone && selected !== "local" ? selected : ""; + const target = registry.data.items.find((cluster) => cluster.name === targetId); + return + {target ? {intl.formatMessage({ id: "web.cluster_configuration.open_named_configuration", defaultMessage: "Open {name} configuration", description: "Legacy backup settings link to selected cluster configuration" }, { name: target.displayName || target.name })} : {intl.formatMessage({ id: "web.cluster_configuration.choose_cluster", defaultMessage: "Choose a cluster", description: "Legacy backup settings link to cluster directory when no valid target exists" })}} + ; +} diff --git a/web/src/components/nodes/NodeEnrollment.test.tsx b/web/src/components/nodes/NodeEnrollment.test.tsx new file mode 100644 index 000000000..b5615eada --- /dev/null +++ b/web/src/components/nodes/NodeEnrollment.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import { server } from "@/test/server"; +import { renderWithQuery } from "@/test/render"; +import { makeClusterInfo, makeUser } from "@/test/factories"; +import { NodeEnrollment } from "./NodeEnrollment"; + +describe("NodeEnrollment", () => { + it("requires explicit cluster selection before creating a join command from All clusters", async () => { + const join = vi.fn(() => HttpResponse.json({ command: "local-command", expiresAt: "later" })); + server.use(http.post("/cluster/nodes:join", join)); + renderWithQuery(); + await waitFor(() => expect(screen.getByRole("button", { name: "Add node" })).toBeEnabled()); + await userEvent.click(screen.getByRole("button", { name: "Add node" })); + expect(join).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: /Enrollment cluster/ })); + await userEvent.click(await screen.findByRole("option", { name: "local" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Generate join command" })).toBeEnabled()); + await userEvent.click(screen.getByRole("button", { name: "Generate join command" })); + expect(await screen.findByText("local-command")).toBeInTheDocument(); + expect(join).toHaveBeenCalledTimes(1); + }); + it.each([[501, /aren't enabled/], [403, /don't have permission/], [500, /operation failed/]] as const)("preserves enrollment error handling for %s", async (status, message) => { + server.use(http.post("/cluster/nodes:join", () => new HttpResponse("failed", { status }))); + renderWithQuery(); + await waitFor(() => expect(screen.getByRole("button", { name: "Add node" })).toBeEnabled()); + await userEvent.click(screen.getByRole("button", { name: "Add node" })); + expect(await screen.findByText(message)).toBeInTheDocument(); + }); + it("copies and dismisses the join command and clears an earlier failure on success", async () => { + let calls = 0; + const clipboard = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText: clipboard } }); + server.use(http.post("/cluster/nodes:join", () => ++calls === 1 ? new HttpResponse("failed", { status: 500 }) : HttpResponse.json({ command: "local-command", expiresAt: "later" }))); + renderWithQuery(); + const add = screen.getByRole("button", { name: "Add node" }); + await waitFor(() => expect(add).toBeEnabled()); + await userEvent.click(add); + await screen.findByText(/operation failed/); + await userEvent.click(add); + await screen.findByText("local-command"); + expect(screen.queryByText(/operation failed/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Copy" })); + expect(clipboard).toHaveBeenCalledWith("local-command"); + await userEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByText("local-command")).not.toBeInTheDocument(); + }); + it("does not show a late local command after changing the explicit target", async () => { + let release: () => void = () => {}; + const pending = new Promise((resolve) => { release = resolve; }); + server.use(http.post("/cluster/nodes:join", async () => { await pending; return HttpResponse.json({ command: "secret-local-command", expiresAt: "later" }); })); + const { rerender, client } = renderWithQuery(); + await waitFor(() => expect(screen.getByRole("button", { name: "Add node" })).toBeEnabled()); + await userEvent.click(screen.getByRole("button", { name: "Add node" })); + rerender(); + await act(async () => { release(); await pending; }); + expect(screen.queryByText("secret-local-command")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add node" })).toBeDisabled(); + expect(screen.getByText(/managed at this remote cluster/)).toBeInTheDocument(); + }); + it("never requests local capabilities or joins on standalone installations", async () => { + const info = vi.fn(() => HttpResponse.json(makeClusterInfo())); + server.use(http.get("/admin/installation", () => HttpResponse.json({ standalone: true, localCluster: false })), http.get("/clusters", () => HttpResponse.json({ items: [] })), http.get("/cluster/info", info)); + renderWithQuery(); + await screen.findByText(/No local workload cluster/); + expect(info).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Add node" })).toBeDisabled(); + }); + it("gates disabled capabilities and missing management permissions", async () => { + server.use(http.get("/cluster/info", () => HttpResponse.json(makeClusterInfo({ clusterOps: false })))); + const { unmount } = renderWithQuery(); + await screen.findByText(/clusterOps.enabled/); + expect(screen.getByRole("button", { name: "Add node" })).toBeDisabled(); + unmount(); + server.use(http.get("/users/me", () => HttpResponse.json(makeUser({ role: "viewer" })))); + renderWithQuery(); + await screen.findByText(/permission to enroll nodes/); + expect(screen.getByRole("button", { name: "Add node" })).toBeDisabled(); + }); + it("retains the older API capability fallback", async () => { + server.use(http.get("/cluster/info", () => HttpResponse.error())); + renderWithQuery(); + await waitFor(() => expect(screen.getByRole("button", { name: "Add node" })).toBeEnabled()); + }); +}); diff --git a/web/src/components/nodes/NodeEnrollment.tsx b/web/src/components/nodes/NodeEnrollment.tsx new file mode 100644 index 000000000..781b39077 --- /dev/null +++ b/web/src/components/nodes/NodeEnrollment.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Button, Card, CardContent, ListBox, ListBoxItem, Select } from "@heroui/react"; +import { Plus } from "lucide-react"; +import { defineMessages, type MessageDescriptor } from "react-intl"; +import { APIError } from "@/lib/api"; +import { can, useMe } from "@/lib/auth"; +import { Cluster, Clusters } from "@/lib/endpoints"; +import { useInstallation } from "@/lib/useInstallation"; +import { useOptionalIntl } from "@/lib/i18n/format"; + +const messages: Record = defineMessages({ + add: { id: "web.nodes.enrollment.add", defaultMessage: "Add node", description: "Nodes: opens node enrollment or creates a command for the selected local cluster" }, + choose: { id: "web.nodes.enrollment.choose", defaultMessage: "Choose a cluster before enrolling a node.", description: "Nodes: prompt requiring an explicit enrollment cluster" }, + target: { id: "web.nodes.enrollment.target", defaultMessage: "Enrollment cluster", description: "Nodes: accessible label for enrollment cluster selection" }, + generate: { id: "web.nodes.enrollment.generate", defaultMessage: "Generate join command", description: "Nodes: creates the join command after selecting an enrollment cluster" }, + remote: { id: "web.nodes.enrollment.remote", defaultMessage: "Node enrollment is managed at this remote cluster.", description: "Nodes: explanation for unsupported remote enrollment" }, + noLocal: { id: "web.nodes.enrollment.no_local", defaultMessage: "No local workload cluster is available on this installation.", description: "Nodes: local enrollment is unavailable on a standalone panel" }, + forbidden: { id: "web.nodes.enrollment.forbidden", defaultMessage: "You don't have permission to enroll nodes.", description: "Nodes: enrollment permission requirement" }, + disabled: { id: "web.nodes.enrollment.disabled", defaultMessage: "Enable clusterOps.enabled in the Helm values to mint node-join tokens.", description: "Nodes: explanation when cluster operations are disabled; clusterOps.enabled is a machine configuration key" }, + notEnabled: { id: "web.nodes.enrollment.not_enabled", defaultMessage: "Cluster operations aren't enabled on this install (set clusterOps.enabled).", description: "Nodes: enrollment error for a disabled cluster-operations endpoint" }, + denied: { id: "web.nodes.enrollment.denied", defaultMessage: "You don't have permission to do that.", description: "Nodes: enrollment request was denied" }, + failed: { id: "web.nodes.enrollment.failed", defaultMessage: "That operation failed. Check the API logs.", description: "Nodes: generic enrollment failure" }, + capabilities: { id: "web.nodes.enrollment.capabilities", defaultMessage: "Couldn't load installation capabilities.", description: "Nodes: enrollment unavailable because installation capabilities failed" }, + clusters: { id: "web.nodes.enrollment.clusters", defaultMessage: "Couldn't load registered clusters.", description: "Nodes: enrollment cluster discovery failed" }, + empty: { id: "web.nodes.enrollment.empty", defaultMessage: "Register a workload cluster on the Clusters page to enroll nodes.", description: "Nodes: no registered enrollment targets exist" }, + dismiss: { id: "web.nodes.enrollment.dismiss", defaultMessage: "Dismiss", description: "Nodes: closes the enrollment result or cluster chooser" }, + copy: { id: "web.nodes.enrollment.copy", defaultMessage: "Copy", description: "Nodes: copies the generated join command" }, + copyFailed: { id: "web.nodes.enrollment.copy_failed", defaultMessage: "Couldn't copy the command. Select and copy it manually.", description: "Nodes: clipboard failure" }, + join: { id: "web.nodes.enrollment.join", defaultMessage: "Join a node to {cluster}", description: "Nodes: title of a generated command, with explicit cluster identity" }, + instruction: { id: "web.nodes.enrollment.instruction", defaultMessage: "Run this on the machine you want to join (token expires {expiresAt}):", description: "Nodes: join command instructions and token expiration" }, +}); + +export function NodeEnrollment({ clusterId }: { clusterId?: string }) { + // Remounting discards tokens, errors and pending results when route scope changes. + return ; +} + +function EnrollmentScope({ clusterId }: { clusterId: string }) { + const intl = useOptionalIntl(); + const { data: me, isPending: identityPending } = useMe(); + const installation = useInstallation(); + const allowed = can(me, "cluster:manage"); + const [open, setOpen] = useState(false); + const [target, setTarget] = useState(clusterId); + const registry = useQuery({ queryKey: ["clusters"], queryFn: () => Clusters.list(), enabled: !clusterId && open && allowed }); + const targets = (registry.data?.items ?? []).filter((cluster) => cluster.name !== "local" || installation.data?.localCluster === true); + const activeTarget = clusterId || target; + return
+ {!clusterId && } + {!clusterId && open && +

{intl.formatMessage(messages.choose)}

+ + {registry.isError &&

{intl.formatMessage(messages.clusters)}

} + {registry.isSuccess && !targets.length &&

{intl.formatMessage(messages.empty)}

} + +
} + {(clusterId || open && activeTarget) && cluster.name === activeTarget)} />} + {!clusterId && !allowed && !identityPending &&

{intl.formatMessage(messages.forbidden)}

} + {!clusterId && installation.isError &&

{intl.formatMessage(messages.capabilities)}

} +
; +} + +function EnrollmentTarget({ clusterId, choosing, registered }: { clusterId: string; choosing: boolean; registered: boolean }) { + const intl = useOptionalIntl(); + const { data: me, isPending: identityPending } = useMe(); + const installation = useInstallation(); + const allowed = can(me, "cluster:manage"); + const local = clusterId === "local" && installation.data?.localCluster === true; + const info = useQuery({ queryKey: ["cluster-info", clusterId], queryFn: ({ signal }) => Cluster.info(clusterId, signal), enabled: local && allowed }); + const [copyFailed, setCopyFailed] = useState(false); + const add = useMutation({ mutationFn: (capturedCluster: string) => Cluster.addNode(capturedCluster) }); + const disabled = !registered || !allowed || !local || info.isPending || info.data?.clusterOps === false || add.isPending; + const reason = !identityPending && !allowed ? messages.forbidden : clusterId !== "local" ? messages.remote + : installation.isError ? messages.capabilities : installation.isSuccess && !local ? messages.noLocal + : info.data?.clusterOps === false ? messages.disabled : undefined; + const error = add.error instanceof APIError && add.error.status === 501 ? messages.notEnabled : add.error instanceof APIError && add.error.status === 403 ? messages.denied : messages.failed; + async function copy() { + try { + if (!navigator.clipboard) throw new Error("Clipboard unavailable"); + await navigator.clipboard.writeText(add.data?.command ?? ""); + setCopyFailed(false); + } catch { setCopyFailed(true); } + } + return
+ + {reason &&

{intl.formatMessage(reason)}

} + {add.error &&

{intl.formatMessage(error)}

} + {add.data && +

{intl.formatMessage(messages.join, { cluster: clusterId })}

+

{intl.formatMessage(messages.instruction, { expiresAt: add.data.expiresAt })}

+ {add.data.command} + + {copyFailed &&

{intl.formatMessage(messages.copyFailed)}

} +
} +
; +} diff --git a/web/src/components/nodes/NodeInventory.test.tsx b/web/src/components/nodes/NodeInventory.test.tsx new file mode 100644 index 000000000..f7b57b34c --- /dev/null +++ b/web/src/components/nodes/NodeInventory.test.tsx @@ -0,0 +1,228 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useState } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { server } from "@/test/server"; +import { renderWithQuery } from "@/test/render"; +import type { FleetInventory, FleetResult } from "@/lib/fleet"; +import * as media from "@/lib/media"; +import { NodeInventory } from "./NodeInventory"; + +function result(items: FleetInventory[], extra: Partial> = {}) { + return { items, scopes: [{ cluster: "east" }, { cluster: "west" }], partial: false, issues: [], totalReturned: items.length, ...extra }; +} +const items: FleetInventory[] = [ + { cluster: "east", name: "East", stats: {}, view: { nodes: [{ name: "same", status: "Ready", cpu: { used: 0, capacity: 4 } }] } }, + { cluster: "west", name: "West", stats: {}, view: { nodes: [{ name: "same", status: "NotReady", cpu: { used: 3, capacity: 4 } }, { name: "unknown" }] } }, +]; + +afterEach(() => vi.restoreAllMocks()); + +describe("NodeInventory", () => { + it("requests all nodes by default and keeps duplicate names in separate rows", async () => { + const requests: Array = []; + server.use(http.get("/fleet/inventory", ({ request }) => { requests.push(new URL(request.url).searchParams.get("cluster")); return HttpResponse.json(result(items)); })); + renderWithQuery(); + expect(await screen.findAllByText("same")).toHaveLength(2); + expect(requests).toEqual([null]); + const rows = screen.getAllByRole("row"); + expect(rows.some((row) => row.textContent?.includes("East") && row.textContent.includes("same"))).toBe(true); + expect(rows.some((row) => row.textContent?.includes("West") && row.textContent.includes("same"))).toBe(true); + }); + it("requests the selected cluster and retains global scopes in the filter", async () => { + const requests: Array = []; + server.use(http.get("/fleet/inventory", ({ request }) => { const cluster = new URL(request.url).searchParams.get("cluster"); requests.push(cluster); return HttpResponse.json(result(cluster ? [items[1]] : [], { partial: !cluster })); })); + renderWithQuery( {}} />); + await screen.findByText("same"); + expect(requests).toContain("west"); + await userEvent.click(screen.getByRole("button", { name: /^Filter\s*1$/ })); + await userEvent.click(screen.getByRole("button", { name: /Filter by cluster/ })); + expect(await screen.findByRole("option", { name: "east" })).toBeInTheDocument(); + }); + it("combines search and status filters and sorts the table", async () => { + server.use(http.get("/fleet/inventory", () => HttpResponse.json(result(items)))); + renderWithQuery(); + await screen.findAllByText("same"); + await userEvent.click(screen.getByRole("button", { name: "Sort by CPU" })); + await userEvent.click(screen.getByRole("button", { name: "Sort by CPU" })); + expect(within(screen.getAllByRole("row")[1]).getByText("West")).toBeInTheDocument(); + await userEvent.type(screen.getByRole("textbox", { name: "Search nodes" }), "east"); + expect(screen.getAllByText("same")).toHaveLength(1); + await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); + await userEvent.click(screen.getByRole("button", { name: /Filter by status/ })); + await userEvent.click(await screen.findByRole("option", { name: "Not ready" })); + expect(screen.getAllByText("same")).toHaveLength(1); + await userEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(await screen.findByText("No nodes match these filters.")).toBeInTheDocument(); + }); + it("does not report unavailable inventory as a healthy empty fleet", async () => { + server.use(http.get("/fleet/inventory", () => HttpResponse.json(result([], { partial: true, issues: [{ cluster: "west", code: "unavailable", message: "Inventory unavailable." }] })))); + renderWithQuery(); + expect(await screen.findByText("Node results are partial.")).toBeInTheDocument(); + expect(screen.getByText("No node data is available for this scope.")).toBeInTheDocument(); + expect(screen.queryByText("0 / 0")).not.toBeInTheDocument(); + }); + it("shows unknown measurements separately from measured zero", async () => { + server.use(http.get("/fleet/inventory", () => HttpResponse.json(result(items)))); + renderWithQuery(); + await screen.findByText("same"); + expect(screen.getAllByRole("row")).toHaveLength(2); + const row = screen.getByRole("row", { name: /same/ }); + expect(within(row).getByText("0%")).toBeInTheDocument(); + expect(within(row).getAllByText("—").length).toBeGreaterThanOrEqual(2); + await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); + }); + it("does not paint a late local response over explicitly selected remote inventory", async () => { + let release: () => void = () => {}; + let localRequested = false; + const pending = new Promise((resolve) => { release = resolve; }); + server.use(http.get("/fleet/inventory", async ({ request }) => { + const cluster = new URL(request.url).searchParams.get("cluster") || "local"; + if (cluster === "local") { localRequested = true; await pending; } + return HttpResponse.json(result([{ cluster, name: cluster, stats: {}, view: { nodes: [{ name: `${cluster}-node`, status: "Ready" }] } }])); + })); + const { client, rerender } = renderWithQuery(); + await waitFor(() => expect(localRequested).toBe(true)); + rerender(); + await screen.findByText("remote-node"); + await act(async () => { release(); await pending; }); + expect(screen.queryByText("local-node")).not.toBeInTheDocument(); + expect(client.getQueryData(["fleet", "inventory", "remote"])).toMatchObject({ items: [{ cluster: "remote" }] }); + }); + it("preserves node roles, labels, uptime and status without requiring optional fields", async () => { + server.use(http.get("/fleet/inventory", () => HttpResponse.json(result([{ cluster: "east", name: "East", stats: {}, view: { nodes: [ + { name: "master-1", status: "Ready", roles: ["master", "control-plane"], labels: ["zone=north"], uptime: "45d 3h 22m" }, + { name: "worker-1", status: "NotReady" }, { name: "unknown-node" }, + ] } }])))); + renderWithQuery(); + await screen.findByText("master-1"); + expect(screen.getByText("master, control-plane")).toBeInTheDocument(); + expect(screen.getByText("zone=north")).toBeInTheDocument(); + expect(screen.getByText("45d 3h 22m")).toBeInTheDocument(); + expect(within(screen.getByRole("row", { name: /worker-1/ })).getByText("Not ready")).toBeInTheDocument(); + expect(within(screen.getByRole("row", { name: /unknown-node/ })).getByText("Unknown")).toBeInTheDocument(); + expect(screen.getAllByRole("row")).toHaveLength(4); + }); + it("reports complete emptiness separately from errors and retries inventory loading", async () => { + let failed = true; + server.use(http.get("/fleet/inventory", () => failed ? new HttpResponse("failed", { status: 500 }) : HttpResponse.json(result([])))); + renderWithQuery(); + await screen.findByText("Node results are unavailable. Try again."); + expect(screen.queryByText("No nodes are reporting in this scope.")).not.toBeInTheDocument(); + failed = false; + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("No nodes are reporting in this scope.")).toBeInTheDocument(); + }); + it("summarizes the visible rows instead of trusting unfiltered cluster counts", async () => { + server.use(http.get("/fleet/inventory", () => HttpResponse.json(result(items)))); + renderWithQuery(); + await screen.findAllByText("same"); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); + await userEvent.type(screen.getByRole("textbox", { name: "Search nodes" }), "east"); + expect(screen.getByText("1 / 1")).toBeInTheDocument(); + }); + it("keeps the mobile summary compact while retaining per-node metrics and coverage", async () => { + vi.spyOn(media, "useMediaQuery").mockReturnValue(true); + server.use(http.get("/fleet/inventory", () => HttpResponse.json(result(items, { partial: true })))); + renderWithQuery(); + await screen.findAllByText("same"); + const summary = within(screen.getByRole("group", { name: "Ready nodes" })); + expect(summary.getByText("1 / 3")).toBeInTheDocument(); + expect(summary.getByText("Reporting clusters")).toBeInTheDocument(); + expect(summary.getByText("2")).toBeInTheDocument(); + expect(summary.getByText("Returned nodes only")).toBeInTheDocument(); + expect(summary.queryByText("CPU")).not.toBeInTheDocument(); + expect(summary.queryByText("Memory")).not.toBeInTheDocument(); + const list = within(screen.getByRole("list", { name: "Node inventory" })); + const cards = list.getAllByRole("listitem"); + expect(cards).toHaveLength(3); + expect(within(cards[0]).getByText("CPU")).toBeInTheDocument(); + expect(within(cards[0]).getByText("Memory")).toBeInTheDocument(); + expect(screen.queryByRole("grid", { name: "Node inventory" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); + expect(screen.getByRole("button", { name: /Filter by cluster/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Filter by status/ })).toBeInTheDocument(); + }); + it("applies cluster and status drafts together and clears filters without clearing search", async () => { + const requests: Array = []; + const changeCluster = vi.fn(); + server.use(http.get("/fleet/inventory", ({ request }) => { + const cluster = new URL(request.url).searchParams.get("cluster"); + requests.push(cluster); + return HttpResponse.json(result(cluster ? items.filter((item) => item.cluster === cluster) : items)); + })); + function InventoryFilters() { + const [clusterId, setClusterId] = useState(""); + return { changeCluster(value); setClusterId(value); }} />; + } + renderWithQuery(); + await screen.findAllByText("same"); + await userEvent.type(screen.getByRole("textbox", { name: "Search nodes" }), "same"); + const filter = screen.getByRole("button", { name: /^Filter$/ }); + await userEvent.click(filter); + await userEvent.click(screen.getByRole("button", { name: /Filter by cluster/ })); + await userEvent.click(await screen.findByRole("option", { name: "West" })); + await userEvent.click(screen.getByRole("button", { name: /Filter by status/ })); + await userEvent.click(await screen.findByRole("option", { name: "Not ready" })); + expect(changeCluster).not.toHaveBeenCalled(); + expect(requests).not.toContain("west"); + expect(filter).toHaveTextContent(/^Filter$/); + expect(screen.getAllByText("same")).toHaveLength(2); + await userEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(requests).toContain("west")); + expect(changeCluster).toHaveBeenLastCalledWith("west"); + await waitFor(() => expect(screen.getAllByText("same")).toHaveLength(1)); + const appliedFilter = screen.getByRole("button", { name: /^Filter\s*2$/ }); + await userEvent.click(appliedFilter); + await userEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(changeCluster).toHaveBeenCalledTimes(1); + expect(appliedFilter).toHaveTextContent(/^Filter\s*2$/); + expect(screen.getAllByText("same")).toHaveLength(1); + await userEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(changeCluster).toHaveBeenLastCalledWith("")); + expect(screen.getByRole("textbox", { name: "Search nodes" })).toHaveValue("same"); + await waitFor(() => expect(screen.getAllByText("same")).toHaveLength(2)); + expect(screen.getByRole("button", { name: /^Filter$/ })).toBeInTheDocument(); + }); + it("discards dismissed drafts and restores the applied values when reopening", async () => { + const changeCluster = vi.fn(); + server.use(http.get("/fleet/inventory", () => HttpResponse.json(result(items)))); + renderWithQuery(); + await screen.findByText("same"); + await userEvent.click(screen.getByRole("button", { name: /^Filter\s*1$/ })); + await userEvent.click(screen.getByRole("button", { name: /Filter by cluster/ })); + await userEvent.click(await screen.findByRole("option", { name: "East" })); + await userEvent.click(screen.getByRole("button", { name: /Filter by status/ })); + await userEvent.click(await screen.findByRole("option", { name: "Ready" })); + screen.getByRole("button", { name: "Clear" }).focus(); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument()); + expect(changeCluster).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: /^Filter\s*1$/ })); + expect(screen.getByRole("button", { name: /Filter by cluster/ })).toHaveTextContent("West"); + expect(screen.getByRole("button", { name: /Filter by status/ })).toHaveTextContent("All statuses"); + }); + it("counts only status in embedded inventory and keeps its fixed cluster when clearing", async () => { + const changeCluster = vi.fn(); + const requests: Array = []; + server.use(http.get("/fleet/inventory", ({ request }) => { requests.push(new URL(request.url).searchParams.get("cluster")); return HttpResponse.json(result(items)); })); + renderWithQuery(); + await screen.findByText("same"); + await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /Filter by status/ })); + await userEvent.click(await screen.findByRole("option", { name: "Ready" })); + await userEvent.click(screen.getByRole("button", { name: "Apply" })); + await userEvent.click(screen.getByRole("button", { name: /^Filter\s*1$/ })); + await userEvent.click(screen.getByRole("button", { name: "Clear" })); + await userEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(screen.getByRole("button", { name: /^Filter$/ })).toBeInTheDocument(); + expect(changeCluster).not.toHaveBeenCalled(); + expect(requests).toEqual(["east"]); + expect(within(screen.getByRole("row", { name: "same" })).getByText("East")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/nodes/NodeInventory.tsx b/web/src/components/nodes/NodeInventory.tsx new file mode 100644 index 000000000..216726835 --- /dev/null +++ b/web/src/components/nodes/NodeInventory.tsx @@ -0,0 +1,226 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Card, Chip, Input, ListBox, ListBoxItem, Select, Table } from "@heroui/react"; +import { ArrowDown, ArrowUp, ArrowUpDown, Cpu, Filter, Layers, Server, MemoryStick } from "lucide-react"; +import { defineMessages, type MessageDescriptor } from "react-intl"; +import { FleetCoverage } from "@/components/FleetScope"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { StatCard } from "@/components/ui/StatCard"; +import { Meter } from "@/components/ui/Meter"; +import { FilterPopover } from "@/components/ui/FilterPopover"; +import { Fleet } from "@/lib/fleet"; +import { filterNodes, flattenNodes, nodeMeasurement, nodeStatus, sortNodes, summarizeNodes, validNodeNumber, type InventoryNode, type NodeSortColumn, type NodeSortDirection } from "@/lib/nodes"; +import { useMediaQuery } from "@/lib/media"; +import { useOptionalIntl } from "@/lib/i18n/format"; +import { formatBytes, formatUptime } from "@/lib/utils"; +import { NodeEnrollment } from "./NodeEnrollment"; + +const messages: Record = defineMessages({ + title: { id: "web.nodes.title", defaultMessage: "Nodes", description: "Nodes inventory page and embedded tab heading" }, + description: { id: "web.nodes.description", defaultMessage: "Monitor nodes and resource usage across your clusters.", description: "Nodes inventory description" }, + clusterDescription: { id: "web.nodes.cluster_description", defaultMessage: "Node inventory for {cluster}.", description: "Nodes inventory description fixed to one cluster" }, + ready: { id: "web.nodes.ready", defaultMessage: "Ready nodes", description: "Nodes: summary count of ready nodes" }, + reporting: { id: "web.nodes.reporting", defaultMessage: "Reporting clusters", description: "Nodes: number of clusters represented by visible nodes" }, + cpu: { id: "web.nodes.cpu", defaultMessage: "CPU", description: "Nodes: CPU usage column and summary label" }, + memory: { id: "web.nodes.memory", defaultMessage: "Memory", description: "Nodes: memory usage column and summary label" }, + pods: { id: "web.nodes.pods", defaultMessage: "Pods", description: "Nodes: pod usage column label" }, + uptime: { id: "web.nodes.uptime", defaultMessage: "Uptime", description: "Nodes: uptime column label" }, + node: { id: "web.nodes.node", defaultMessage: "Node", description: "Nodes: node identity column label" }, + cluster: { id: "web.nodes.cluster", defaultMessage: "Cluster", description: "Nodes: cluster identity column label" }, + clusterIdentity: { id: "web.nodes.cluster_identity", defaultMessage: "{name} · {id}", description: "Nodes: mobile row cluster identity when its display name differs from its registration ID; {name} is the display name and {id} is the registration ID" }, + status: { id: "web.nodes.status", defaultMessage: "Status", description: "Nodes: status column label" }, + visible: { id: "web.nodes.visible", defaultMessage: "Matching nodes", description: "Nodes: scope of the filtered summary" }, + returned: { id: "web.nodes.returned", defaultMessage: "Returned nodes only", description: "Nodes: incomplete coverage summary qualifier" }, + measured: { id: "web.nodes.measured", defaultMessage: "Capacity-weighted usage", description: "Nodes: CPU and memory summary uses summed usage and capacity" }, + unknownMetric: { id: "web.nodes.unknown_metric", defaultMessage: "Measurement unavailable", description: "Nodes: absent or incomplete resource usage summary" }, + unavailable: { id: "web.nodes.unavailable", defaultMessage: "Inventory unavailable", description: "Nodes: inventory cannot provide a reliable count" }, + search: { id: "web.nodes.search", defaultMessage: "Search nodes", description: "Nodes: accessible search field label" }, + searchPlaceholder: { id: "web.nodes.search_placeholder", defaultMessage: "Search nodes, clusters or labels…", description: "Nodes: search field placeholder" }, + filterCluster: { id: "web.nodes.filter_cluster", defaultMessage: "Filter by cluster", description: "Nodes: cluster filter accessible label" }, + filterStatus: { id: "web.nodes.filter_status", defaultMessage: "Filter by status", description: "Nodes: status filter accessible label" }, + filter: { id: "web.nodes.filter", defaultMessage: "Filter", description: "Nodes: button that opens the Cluster and Status filter panel, with a count of applied filters; search is not included in the count" }, + allClusters: { id: "web.nodes.all_clusters", defaultMessage: "All clusters", description: "Nodes: option to include every authorized cluster" }, + allStatuses: { id: "web.nodes.all_statuses", defaultMessage: "All statuses", description: "Nodes: option to include every node status" }, + statusReady: { id: "web.nodes.status_ready", defaultMessage: "Ready", description: "Nodes: healthy node status" }, + statusNotReady: { id: "web.nodes.status_not_ready", defaultMessage: "Not ready", description: "Nodes: unhealthy node status" }, + statusUnknown: { id: "web.nodes.status_unknown", defaultMessage: "Unknown", description: "Nodes: unreported or unrecognized status filter" }, + coverage: { id: "web.nodes.coverage", defaultMessage: "Node results", description: "Nodes: resource label in fleet coverage notice" }, + retry: { id: "web.nodes.retry", defaultMessage: "Retry", description: "Nodes: retries failed inventory loading" }, + loading: { id: "web.nodes.loading", defaultMessage: "Loading nodes…", description: "Nodes: initial inventory loading state" }, + noData: { id: "web.nodes.no_data", defaultMessage: "No node data is available for this scope.", description: "Nodes: no returned data when inventory is incomplete or failed" }, + noMatch: { id: "web.nodes.no_match", defaultMessage: "No nodes match these filters.", description: "Nodes: search and status filters matched no nodes" }, + empty: { id: "web.nodes.empty", defaultMessage: "No nodes are reporting in this scope.", description: "Nodes: complete empty inventory" }, + list: { id: "web.nodes.list", defaultMessage: "Node inventory", description: "Nodes: accessible table and mobile list name" }, + sort: { id: "web.nodes.sort", defaultMessage: "Sort by {column}", description: "Nodes: accessible column sort button label" }, + sortBy: { id: "web.nodes.sort_by", defaultMessage: "Sort nodes by", description: "Nodes: accessible mobile sort column selector" }, + ascending: { id: "web.nodes.ascending", defaultMessage: "Ascending", description: "Nodes: ascending sort direction" }, + descending: { id: "web.nodes.descending", defaultMessage: "Descending", description: "Nodes: descending sort direction" }, + cores: { id: "web.nodes.cores", defaultMessage: "{used} / {capacity} cores", description: "Nodes: measured CPU usage and total core capacity" }, + memoryUsage: { id: "web.nodes.memory_usage", defaultMessage: "{used} / {capacity}", description: "Nodes: memory usage and capacity, both formatted sizes" }, + ofCapacity: { id: "web.nodes.of_capacity", defaultMessage: "of {capacity}", description: "Nodes: capacity shown when usage is unreported" }, + podUsage: { id: "web.nodes.pod_usage", defaultMessage: "{used} / {capacity}", description: "Nodes: pod count and capacity; either missing value is an em dash" }, + percent: { id: "web.nodes.percent", defaultMessage: "{value}%", description: "Nodes: resource usage percentage" }, + readyCount: { id: "web.nodes.ready_count", defaultMessage: "{ready} / {total}", description: "Nodes: ready count out of visible returned nodes" }, + count: { id: "web.nodes.count", defaultMessage: "{count, plural, one {# node} other {# nodes}}", description: "Nodes: count of visible returned rows" }, + partialCount: { id: "web.nodes.partial_count", defaultMessage: "{count, plural, one {# node} other {# nodes}} · Returned nodes only", description: "Nodes: footer count when inventory coverage is partial; {count} is the number of matching nodes returned, not the complete fleet count" }, + missingInventory: { id: "web.nodes.missing_inventory", defaultMessage: "Node inventory was not reported.", description: "Nodes: a returned cluster omitted its node inventory" }, +}); + +const columns: NodeSortColumn[] = ["name", "cluster", "status", "cpu", "memory", "pods", "uptime"]; +const columnMessage = { name: messages.node, cluster: messages.cluster, status: messages.status, cpu: messages.cpu, memory: messages.memory, pods: messages.pods, uptime: messages.uptime }; + +export function NodeInventory({ clusterId = "", onClusterChange, embedded = false }: { clusterId?: string; onClusterChange?: (value: string) => void; embedded?: boolean }) { + const intl = useOptionalIntl(); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const [draftCluster, setDraftCluster] = useState(clusterId); + const [draftStatus, setDraftStatus] = useState(status); + const [sortColumn, setSortColumn] = useState("name"); + const [sortDirection, setSortDirection] = useState("ascending"); + const mobile = useMediaQuery("(max-width: 767px)"); + const inventory = useQuery({ queryKey: ["fleet", "inventory", clusterId], queryFn: ({ signal }) => Fleet.inventory({ cluster: clusterId || undefined }, signal), refetchInterval: 15_000 }); + // Keep the all-cluster scope list alive while filtering so capped or unavailable + // clusters remain selectable. Identical all-cluster queries share their cache. + const scopes = useQuery({ queryKey: ["fleet", "inventory", ""], queryFn: ({ signal }) => Fleet.inventory({}, signal), enabled: !embedded, staleTime: 30_000 }); + const selectedItems = useMemo(() => (inventory.data?.items ?? []).filter((item) => !clusterId || item.cluster === clusterId), [inventory.data, clusterId]); + const missingInventory = selectedItems.filter((item) => !Array.isArray(item.view.nodes)); + const partial = inventory.data?.partial === true || missingInventory.length > 0; + const issues = [...inventory.data?.issues ?? [], ...missingInventory.map((item) => ({ cluster: item.cluster, code: "unavailable", message: intl.formatMessage(messages.missingInventory) }))]; + const rows = useMemo(() => flattenNodes(selectedItems), [selectedItems]); + const filtered = useMemo(() => filterNodes(rows, search, status), [rows, search, status]); + const visible = useMemo(() => sortNodes(filtered, sortColumn, sortDirection), [filtered, sortColumn, sortDirection]); + const summary = summarizeNodes(filtered); + const unknownCounts = inventory.isPending || !!inventory.error || partial && !rows.length; + const choices = [...new Set([clusterId, ...[scopes.data, inventory.data].flatMap((data) => [ + ...(data?.items.map((item) => item.cluster) ?? []), ...(data?.scopes?.map((scope) => scope.cluster) ?? []), ...(data?.issues.map((issue) => issue.cluster) ?? []), + ])].filter(Boolean))].sort(); + const names = new Map([...(scopes.data?.items ?? []), ...(inventory.data?.items ?? [])].map((item) => [item.cluster, item.name || item.cluster])); + const statusLabel = (value: string) => intl.formatMessage(value === "Ready" ? messages.statusReady : value === "NotReady" ? messages.statusNotReady : messages.statusUnknown); + const empty = inventory.isPending ? messages.loading : inventory.error || partial ? messages.noData : search || status ? messages.noMatch : messages.empty; + const sort = (column: NodeSortColumn) => { setSortColumn(column); setSortDirection(column === sortColumn && sortDirection === "ascending" ? "descending" : "ascending"); }; + const percentage = (value: number | undefined) => value === undefined || inventory.error ? "—" : intl.formatMessage(messages.percent, { value: String(Math.round(value)) }); + const qualifier = intl.formatMessage(unknownCounts ? messages.unavailable : partial ? messages.returned : messages.visible); + const emptyValue = intl.formatMessage({ id: "web.common.empty_value", defaultMessage: "—", description: "Shown in place of a value that is not set" }); + const filterCount = Number(!embedded && Boolean(clusterId)) + Number(Boolean(status)); + const onFiltersOpenChange = (open: boolean) => { + if (open) { + setDraftCluster(clusterId); + setDraftStatus(status); + } + setFiltersOpen(open); + }; + + return
+ } /> + {mobile ?
+
+
{intl.formatMessage(messages.ready)}
{unknownCounts ? emptyValue : intl.formatMessage(messages.readyCount, { ready: summary.ready, total: summary.total })}
+
{intl.formatMessage(messages.reporting)}
{unknownCounts ? emptyValue : summary.clusters}
+
+

{qualifier}

+
:
+ } accent="success" /> + } /> + } /> + } accent="violet" /> +
} +
+ setSearch(event.target.value)} className="min-w-0 flex-1 basis-40 max-w-xs" type="text" /> +
+ { + if (!embedded) onClusterChange?.(draftCluster); + setStatus(draftStatus); + setFiltersOpen(false); + }} + onClear={() => { setDraftCluster(""); setDraftStatus(""); }} + fields={<> + {!embedded &&
+
{intl.formatMessage(messages.cluster)}
+ ({ value, label: names.get(value) || value }))]} /> +
} +
+
{intl.formatMessage(messages.status)}
+ ({ value, label: statusLabel(value) }))]} /> +
+ }> +
+ {intl.formatMessage(messages.filter)}{filterCount > 0 && {filterCount}} +
+
+
+
+ + {!!inventory.error && } + {mobile ? <> +
setSortColumn(value as NodeSortColumn)} choices={columns.map((value) => ({ value, label: intl.formatMessage(columnMessage[value]) }))} />
+
+ {!visible.length && {intl.formatMessage(empty)}} + {visible.map((row) => +
+

{row.clusterName !== row.cluster ? intl.formatMessage(messages.clusterIdentity, { name: row.clusterName, id: row.cluster }) : row.clusterName}

+
+
{intl.formatMessage(messages.pods)} {intl.formatMessage(messages.uptime)} {nodeUptime(row, intl)}
+
)} +
+ :
+ + {columns.map((column) => + + )} +
{intl.formatMessage(empty)}
}> + {visible.map((row) => + +
{row.clusterName}
{row.clusterName !== row.cluster &&
{row.cluster}
}
+ + + + + {nodeUptime(row, intl)} +
)} +
+
+
} +

{unknownCounts ? intl.formatMessage(messages.unavailable) : intl.formatMessage(partial ? messages.partialCount : messages.count, { count: visible.length })}

+
; +} + +function NodeSelect({ label, value, onChange, choices, className = "w-full md:w-48 md:shrink-0" }: { label: string; value: string; onChange: (value: string) => void; choices: { value: string; label: string }[]; className?: string }) { + return ; +} + +function NodeIdentity({ row }: { row: InventoryNode }) { + return
{row.node.name}
{row.node.roles?.length ?
{row.node.roles.join(", ")}
: null}{row.node.labels?.length ?
{row.node.labels.map((label) => {label})}
: null}
; +} + +function NodeStatus({ row }: { row: InventoryNode }) { + const intl = useOptionalIntl(); + const status = nodeStatus(row.node); + return {status === "Unknown" && row.node.status?.trim() ? row.node.status : intl.formatMessage(status === "Ready" ? messages.statusReady : status === "NotReady" ? messages.statusNotReady : messages.statusUnknown)}; +} + +function NodeMetric({ row, kind }: { row: InventoryNode; kind: "cpu" | "memory" }) { + const intl = useOptionalIntl(); + const metric = row.node[kind]; + const percent = nodeMeasurement(metric); + const capacity = validNodeNumber(metric?.capacity) && metric.capacity > 0 ? metric.capacity : undefined; + const sub = capacity !== undefined ? percent !== undefined + ? intl.formatMessage(kind === "cpu" ? messages.cores : messages.memoryUsage, { used: kind === "cpu" ? intl.formatNumber(metric?.used ?? 0, { maximumFractionDigits: 2 }) : formatBytes(metric?.used ?? 0), capacity: kind === "cpu" ? intl.formatNumber(capacity) : formatBytes(capacity) }) + : intl.formatMessage(messages.ofCapacity, { capacity: kind === "cpu" ? intl.formatNumber(capacity) : formatBytes(capacity) }) : undefined; + return ; +} + +function NodePods({ row }: { row: InventoryNode }) { + const intl = useOptionalIntl(); + const pods = row.node.pods; + const used = validNodeNumber(pods?.used) && Number.isInteger(pods.used) ? pods.used : undefined; + const capacity = validNodeNumber(pods?.capacity) && Number.isInteger(pods.capacity) && pods.capacity > 0 ? pods.capacity : undefined; + return <>{used === undefined && capacity === undefined ? "—" : intl.formatMessage(messages.podUsage, { used: used ?? "—", capacity: capacity ?? "—" })}; +} + +function nodeUptime(row: InventoryNode, intl: ReturnType) { + if (row.node.uptime?.trim()) return row.node.uptime; + const started = row.node.startedAt ? Date.parse(row.node.startedAt) : NaN; + return Number.isFinite(started) && started <= Date.now() ? formatUptime(row.node.startedAt, intl) : "—"; +} diff --git a/web/src/components/ui/Breadcrumbs.test.tsx b/web/src/components/ui/Breadcrumbs.test.tsx index 5f21e9918..e49fd27c7 100644 --- a/web/src/components/ui/Breadcrumbs.test.tsx +++ b/web/src/components/ui/Breadcrumbs.test.tsx @@ -15,6 +15,24 @@ vi.mock("@tanstack/react-router", () => ({ })); describe("buildCrumbs", () => { + it("keeps fleet nodes separate from cluster details", () => { + expect(buildCrumbs("/nodes")).toEqual([ + { label: "gameplane", to: "/" }, + { label: "Nodes", to: "/nodes" }, + ]); + expect(buildCrumbs("/clusters/remote-demo")).toEqual([ + { label: "gameplane", to: "/" }, + { label: "Clusters", to: "/clusters" }, + { label: "remote-demo", to: "/clusters/remote-demo" }, + { label: "Overview" }, + ]); + }); + + it.each(["nodes", "configuration"])("includes the active cluster %s tab", (tab) => { + const crumbs = buildCrumbs("/clusters/east", undefined, tab); + expect(crumbs.at(-1)?.label).toBe(tab === "nodes" ? "Nodes" : "Configuration"); + expect(crumbs.at(-2)).toEqual({ label: "east", to: "/clusters/east" }); + }); it("returns home and dashboard crumb for root path", () => { const crumbs = buildCrumbs("/"); expect(crumbs).toEqual([ diff --git a/web/src/components/ui/Breadcrumbs.tsx b/web/src/components/ui/Breadcrumbs.tsx index 541e5b7d2..69784be8f 100644 --- a/web/src/components/ui/Breadcrumbs.tsx +++ b/web/src/components/ui/Breadcrumbs.tsx @@ -16,6 +16,7 @@ const routeLabelMessages = defineMessages({ modules: { id: "web.components.ui.breadcrumbs.modules", defaultMessage: "Modules", description: "Breadcrumb: label for the modules crumb" }, cluster: { id: "web.components.ui.breadcrumbs.cluster", defaultMessage: "Cluster", description: "Breadcrumb: label for the cluster crumb" }, clusters: { id: "web.components.ui.breadcrumbs.clusters", defaultMessage: "Clusters", description: "Breadcrumb: label for the clusters crumb" }, + nodes: { id: "web.components.ui.breadcrumbs.nodes", defaultMessage: "Nodes", description: "Breadcrumb: label for the node inventory crumb" }, users: { id: "web.components.ui.breadcrumbs.users_rbac", defaultMessage: "Users & RBAC", description: "Breadcrumb: label for the users and roles crumb" }, admin: { id: "web.components.ui.breadcrumbs.settings", defaultMessage: "Settings", description: "Breadcrumb: label for the settings crumb (also used for the theme settings page)" }, audit: { id: "web.components.ui.breadcrumbs.audit_log", defaultMessage: "Audit log", description: "Breadcrumb: label for the audit log crumb" }, @@ -28,6 +29,12 @@ const dashboardLeafMessage = defineMessages({ dashboard: { id: "web.components.ui.breadcrumbs.dashboard", defaultMessage: "Dashboard", description: "Breadcrumb: label of the leaf crumb on the root (dashboard) page" }, }).dashboard; +const clusterTabMessages = defineMessages({ + overview: { id: "web.cluster_detail.overview", defaultMessage: "Overview", description: "Cluster detail overview tab" }, + nodes: { id: "web.cluster_detail.nodes", defaultMessage: "Nodes", description: "Cluster detail nodes tab" }, + configuration: { id: "web.cluster_detail.configuration", defaultMessage: "Configuration", description: "Cluster detail configuration tab" }, +}); + function routeLabel(intl: IntlShape, part: string): string { return Object.prototype.hasOwnProperty.call(routeLabelMessages, part) ? intl.formatMessage(routeLabelMessages[part as keyof typeof routeLabelMessages]) @@ -41,7 +48,7 @@ function routeLabel(intl: IntlShape, part: string): string { * when on the root path. Pass the app's intl to translate the labels; * without it the English labels are returned. */ -export function buildCrumbs(pathname: string, intl: IntlShape = englishIntl): Crumb[] { +export function buildCrumbs(pathname: string, intl: IntlShape = englishIntl, clusterTab?: string): Crumb[] { const crumbs: Crumb[] = [{ label: "gameplane", to: "/" }]; const parts = pathname.split("/").filter(Boolean); let acc = ""; @@ -56,6 +63,10 @@ export function buildCrumbs(pathname: string, intl: IntlShape = englishIntl): Cr crumbs.push({ label, to: acc }); } if (parts.length === 0) crumbs.push({ label: intl.formatMessage(dashboardLeafMessage) }); + if (parts[0] === "clusters" && parts.length === 2) { + const tab = clusterTab === "nodes" || clusterTab === "configuration" ? clusterTab : "overview"; + crumbs.push({ label: intl.formatMessage(clusterTabMessages[tab]) }); + } return crumbs; } diff --git a/web/src/components/ui/SettingsNav.tsx b/web/src/components/ui/SettingsNav.tsx index 90574af0c..b913fe2d8 100644 --- a/web/src/components/ui/SettingsNav.tsx +++ b/web/src/components/ui/SettingsNav.tsx @@ -1,7 +1,6 @@ import type { ComponentType } from "react"; import { Link } from "@tanstack/react-router"; import { - Archive, Bell, Boxes, Cog, @@ -30,7 +29,6 @@ export type SettingsNavKey = | "general" | "theme" | "auth" - | "backups" | "modules" | "modRegistries" | "notifications" @@ -46,7 +44,6 @@ const navMessages = defineMessages({ general: { id: "web.components.ui.settings_nav.general", defaultMessage: "General", description: "Settings nav: label of the General settings section" }, theme: { id: "web.components.ui.settings_nav.theme", defaultMessage: "Theme", description: "Settings nav: label of the Theme settings page" }, auth: { id: "web.components.ui.settings_nav.authentication", defaultMessage: "Authentication", description: "Settings nav: label of the Authentication settings section" }, - backups: { id: "web.components.ui.settings_nav.backup_destinations", defaultMessage: "Backup destinations", description: "Settings nav: label of the Backup destinations settings section" }, modules: { id: "web.components.ui.settings_nav.module_sources", defaultMessage: "Module sources", description: "Settings nav: label of the Module sources settings section" }, modRegistries: { id: "web.components.ui.settings_nav.mod_registries", defaultMessage: "Mod registries", description: "Settings nav: label of the Mod registries settings section" }, notifications: { id: "web.components.ui.settings_nav.notifications", defaultMessage: "Notifications", description: "Settings nav: label of the Notifications settings section" }, @@ -59,7 +56,6 @@ const navItems: Array<{ key: SettingsNavKey; label: MessageDescriptor; icon: Com { key: "general", label: navMessages.general, icon: Cog }, { key: "theme", label: navMessages.theme, icon: Palette }, { key: "auth", label: navMessages.auth, icon: ShieldCheck }, - { key: "backups", label: navMessages.backups, icon: Archive }, { key: "modules", label: navMessages.modules, icon: Boxes }, { key: "modRegistries", label: navMessages.modRegistries, icon: Key }, { key: "notifications", label: navMessages.notifications, icon: Bell }, @@ -80,7 +76,7 @@ export function SettingsNav({ onSelect, showAdminSections, }: { - active: SettingsNavKey; + active?: SettingsNavKey; onSelect: (key: SettingsSectionKey) => void; /** false hides every admin section (they need config:manage), leaving only Theme. */ showAdminSections: boolean; diff --git a/web/src/lib/fleetLocation.test.tsx b/web/src/lib/fleetLocation.test.tsx index adeffe48d..a18bf3d35 100644 --- a/web/src/lib/fleetLocation.test.tsx +++ b/web/src/lib/fleetLocation.test.tsx @@ -19,7 +19,7 @@ import { server } from "@/test/server"; function LocationControls() { const [location, choose] = useFleetLocation(); return <> - {location || "All locations"} + {location || "All clusters"} Servers @@ -46,7 +46,7 @@ describe("fleet location follows router navigation", () => { expect(await screen.findByTestId("location")).toHaveTextContent("remote"); await userEvent.click(screen.getByRole("link", { name: "Servers" })); await waitFor(() => expect(history.location.href).toBe("/servers")); - expect(screen.getByTestId("location")).toHaveTextContent("All locations"); + expect(screen.getByTestId("location")).toHaveTextContent("All clusters"); }); it("tracks back and forward and replaces a filter without dropping other search fields", async () => { @@ -62,20 +62,20 @@ describe("fleet location follows router navigation", () => { await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("remote")); await userEvent.click(screen.getByRole("button", { name: "Clear location" })); await waitFor(() => expect(router.state.location.search).toEqual({ tab: "restores", note: "keep" })); - expect(screen.getByTestId("location")).toHaveTextContent("All locations"); + expect(screen.getByTestId("location")).toHaveTextContent("All clusters"); expect(history.length).toBe(2); await userEvent.click(screen.getByRole("button", { name: "Choose remote" })); await waitFor(() => expect(router.state.location.search).toEqual({ tab: "restores", cluster: "remote", note: "keep" })); expect(history.length).toBe(2); }); - it("treats an empty or non-string location as all locations", async () => { + it("treats an empty or non-string location as all clusters", async () => { const history = createMemoryHistory({ initialEntries: ["/servers?cluster="] }); const { router } = mountRouter(history); - expect(await screen.findByTestId("location")).toHaveTextContent("All locations"); + expect(await screen.findByTestId("location")).toHaveTextContent("All clusters"); act(() => history.push("/servers?cluster=%5B%22remote%22%5D")); await waitFor(() => expect(router.state.location.search.cluster).toEqual(["remote"])); - expect(screen.getByTestId("location")).toHaveTextContent("All locations"); + expect(screen.getByTestId("location")).toHaveTextContent("All clusters"); }); it("preserves Backups tab and scope when filtering, then resets and restores both on navigation", async () => { @@ -98,7 +98,7 @@ describe("fleet location follows router navigation", () => { await waitFor(() => expect(router.state.location.search.tab).toBe("schedules")); await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); expect(screen.queryByRole("button", { name: /Filter by phase/ })).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: /Filter by location/ })); + await userEvent.click(screen.getByRole("button", { name: /Filter by cluster/ })); await userEvent.click(await screen.findByRole("option", { name: "remote" })); await userEvent.click(screen.getByRole("button", { name: "Apply" })); await waitFor(() => expect(scheduleScopes).toContain("remote")); @@ -110,26 +110,26 @@ describe("fleet location follows router navigation", () => { await waitFor(() => expect(router.state.location.search).toEqual({})); expect(screen.getByRole("tab", { name: "Backups" })).toHaveAttribute("aria-selected", "true"); await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); - expect(screen.getByRole("button", { name: /Filter by location/ })).toHaveTextContent("All locations"); + expect(screen.getByRole("button", { name: /Filter by cluster/ })).toHaveTextContent("All clusters"); screen.getByRole("button", { name: "Clear" }).focus(); await userEvent.keyboard("{Escape}"); - expect(screen.queryByRole("button", { name: /Filter by location/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); act(() => history.back()); await waitFor(() => expect(screen.getByRole("tab", { name: "Schedules" })).toHaveAttribute("aria-selected", "true")); await userEvent.click(screen.getByRole("button", { name: /^Filter\s*1$/ })); - expect(screen.getByRole("button", { name: /Filter by location/ })).toHaveTextContent("remote"); + expect(screen.getByRole("button", { name: /Filter by cluster/ })).toHaveTextContent("remote"); screen.getByRole("button", { name: "Clear" }).focus(); await userEvent.keyboard("{Escape}"); - expect(screen.queryByRole("button", { name: /Filter by location/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); expect(router.state.location.search).toMatchObject({ tab: "schedules", cluster: "remote" }); act(() => history.forward()); await waitFor(() => expect(screen.getByRole("tab", { name: "Backups" })).toHaveAttribute("aria-selected", "true")); await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); - expect(screen.getByRole("button", { name: /Filter by location/ })).toHaveTextContent("All locations"); + expect(screen.getByRole("button", { name: /Filter by cluster/ })).toHaveTextContent("All clusters"); screen.getByRole("button", { name: "Clear" }).focus(); await userEvent.keyboard("{Escape}"); - expect(screen.queryByRole("button", { name: /Filter by location/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); } finally { unmount(); history.destroy(); diff --git a/web/src/lib/nodes.test.ts b/web/src/lib/nodes.test.ts new file mode 100644 index 000000000..8543ea353 --- /dev/null +++ b/web/src/lib/nodes.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { flattenNodes, filterNodes, nodeMeasurement, sortNodes, summarizeNodes } from "./nodes"; +import type { FleetInventory } from "./fleet"; + +const inventory: FleetInventory[] = [ + { cluster: "east", name: "East", stats: {}, view: { nodes: [{ name: "same", status: "Ready", cpu: { used: 0, capacity: 4 } }] } }, + { cluster: "west", name: "West", stats: {}, view: { nodes: [{ name: "same", status: "NotReady", cpu: { used: 2, capacity: 4 } }, { name: "unknown", cpu: { capacity: 8 } }] } }, +]; + +describe("node inventory", () => { + it("keeps names intact and separates identities by cluster", () => { + const rows = flattenNodes(inventory); + expect(rows.map((row) => row.node.name)).toEqual(["same", "same", "unknown"]); + expect(new Set(rows.map((row) => row.key)).size).toBe(3); + }); + it("searches cluster, name, roles and labels and combines status", () => { + expect(filterNodes(flattenNodes(inventory), "WEST", "NotReady").map((row) => row.cluster)).toEqual(["west"]); + expect(filterNodes(flattenNodes(inventory), "", "Unknown")).toHaveLength(1); + expect(filterNodes(flattenNodes([{ ...inventory[0], view: { nodes: [{ name: "worker", roles: ["control-plane"], labels: ["zone=north"] }] } }]), "north", "")).toHaveLength(1); + }); + it("keeps unknown values last in either numeric sort direction", () => { + const rows = flattenNodes(inventory); + expect(sortNodes(rows, "cpu", "ascending").map((row) => row.cluster)).toEqual(["east", "west", "west"]); + expect(sortNodes(rows, "cpu", "descending").map((row) => row.node.name)).toEqual(["same", "same", "unknown"]); + expect(sortNodes(rows, "cpu", "descending")[0].cluster).toBe("west"); + }); + it("distinguishes zero from absent, negative, nonfinite and invalid capacity readings", () => { + expect(nodeMeasurement({ used: 0, capacity: 4 })).toBe(0); + for (const metric of [{ capacity: 4 }, { used: -1, capacity: 4 }, { used: Infinity, capacity: 4 }, { used: 1, capacity: 0 }, { used: 1, capacity: Infinity }]) expect(nodeMeasurement(metric)).toBeUndefined(); + }); + it("summarizes visible returned nodes without calling missing readings idle", () => { + const rows = flattenNodes(inventory); + expect(summarizeNodes(rows)).toMatchObject({ ready: 1, total: 3, clusters: 2, cpu: undefined }); + expect(summarizeNodes(rows.slice(0, 2)).cpu).toBe(25); + }); +}); diff --git a/web/src/lib/nodes.ts b/web/src/lib/nodes.ts new file mode 100644 index 000000000..378f25105 --- /dev/null +++ b/web/src/lib/nodes.ts @@ -0,0 +1,81 @@ +import type { ClusterNode } from "@/types"; +import type { FleetInventory } from "./fleet"; + +export interface InventoryNode { + key: string; + cluster: string; + clusterName: string; + node: ClusterNode; +} +export type NodeSortColumn = "name" | "cluster" | "status" | "cpu" | "memory" | "pods" | "uptime"; +export type NodeSortDirection = "ascending" | "descending"; + +export function flattenNodes(inventory: FleetInventory[]): InventoryNode[] { + return inventory.flatMap((item) => (item.view.nodes ?? []).map((node) => ({ + key: JSON.stringify([item.cluster, node.name]), cluster: item.cluster, + clusterName: item.name || item.cluster, node, + }))); +} + +export function nodeStatus(node: ClusterNode): string { + return node.status === "Ready" || node.status === "NotReady" ? node.status : "Unknown"; +} + +export function validNodeNumber(value: number | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export function nodeMeasurement(metric?: { used?: number; capacity?: number }): number | undefined { + if (!validNodeNumber(metric?.used) || !validNodeNumber(metric?.capacity) || metric.capacity === 0) return undefined; + const percent = metric.used / metric.capacity * 100; + return Number.isFinite(percent) ? percent : undefined; +} + +export function filterNodes(rows: InventoryNode[], search: string, status: string): InventoryNode[] { + const query = search.trim().toLocaleLowerCase(); + return rows.filter(({ cluster, clusterName, node }) => (!status || nodeStatus(node) === status) && + (!query || [node.name, cluster, clusterName, ...(node.roles ?? []), ...(node.labels ?? [])].some((value) => value.toLocaleLowerCase().includes(query)))); +} + +function uptimeSeconds(node: ClusterNode): number | undefined { + if (node.uptime) { + // The legacy API can supply an elapsed duration instead of startedAt. + const matches = [...node.uptime.matchAll(/(\d+(?:\.\d+)?)\s*(d|h|m|s)/g)]; + if (matches.length && node.uptime.replace(/(\d+(?:\.\d+)?)\s*(d|h|m|s)/g, "").trim() === "") { + const factors: Record = { d: 86400, h: 3600, m: 60, s: 1 }; + return matches.reduce((sum, match) => sum + Number(match[1]) * factors[match[2]], 0); + } + } + const started = node.startedAt ? Date.parse(node.startedAt) : NaN; + return Number.isFinite(started) && started <= Date.now() ? (Date.now() - started) / 1000 : undefined; +} + +export function sortNodes(rows: InventoryNode[], column: NodeSortColumn, direction: NodeSortDirection): InventoryNode[] { + const value = (row: InventoryNode): string | number | undefined => { + if (column === "name") return row.node.name; + if (column === "cluster") return row.clusterName; + if (column === "status") return nodeStatus(row.node) === "Unknown" ? undefined : nodeStatus(row.node); + if (column === "cpu" || column === "memory") return nodeMeasurement(row.node[column]); + if (column === "pods") return validNodeNumber(row.node.pods?.used) && Number.isInteger(row.node.pods.used) ? row.node.pods.used : undefined; + return uptimeSeconds(row.node); + }; + return [...rows].sort((a, b) => { + const left = value(a), right = value(b); + // Missing readings stay last in both directions, separate from measured zero. + if (left === undefined || right === undefined) return left === right ? a.key.localeCompare(b.key) : left === undefined ? 1 : -1; + const comparison = typeof left === "number" && typeof right === "number" ? left - right : String(left).localeCompare(String(right), undefined, { numeric: true }); + return (comparison || a.key.localeCompare(b.key)) * (direction === "ascending" ? 1 : -1); + }); +} + +export function summarizeNodes(rows: InventoryNode[]) { + const usage = (kind: "cpu" | "memory") => { + if (!rows.length || rows.some(({ node }) => nodeMeasurement(node[kind]) === undefined)) return undefined; + const used = rows.reduce((sum, { node }) => sum + (node[kind]?.used ?? 0), 0); + const capacity = rows.reduce((sum, { node }) => sum + (node[kind]?.capacity ?? 0), 0); + const percent = used / capacity * 100; + return Number.isFinite(percent) ? percent : undefined; + }; + return { ready: rows.filter(({ node }) => node.status === "Ready").length, total: rows.length, + clusters: new Set(rows.map((row) => row.cluster)).size, cpu: usage("cpu"), memory: usage("memory") }; +} diff --git a/web/src/lib/useServerCapabilities.ts b/web/src/lib/useServerCapabilities.ts index bcff1407b..7edb5bc83 100644 --- a/web/src/lib/useServerCapabilities.ts +++ b/web/src/lib/useServerCapabilities.ts @@ -12,7 +12,7 @@ const MESSAGES = defineMessages({ }, capture_unavailable: { id: "web.lib.use_server_capabilities.capture_unavailable", - defaultMessage: "Capture limits are unavailable for this location.", + defaultMessage: "Capture limits are unavailable for this cluster.", description: "Error shown when the server reports packet capture limits that are not usable", }, }); diff --git a/web/src/pages/ClusterDetail.test.tsx b/web/src/pages/ClusterDetail.test.tsx new file mode 100644 index 000000000..9637d5b23 --- /dev/null +++ b/web/src/pages/ClusterDetail.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { screen } from "@testing-library/react"; +import { server } from "@/test/server"; +import { renderWithQuery } from "@/test/render"; +import { setCurrentCluster } from "@/lib/cluster"; +const route = vi.hoisted(() => ({ clusterId: "east", tab: "overview" })); +vi.mock("@tanstack/react-router", () => ({ useParams: () => ({ clusterId: route.clusterId }), useSearch: () => ({ tab: route.tab }), useNavigate: () => vi.fn() })); +vi.mock("@/components/nodes/NodeInventory", () => ({ NodeInventory: ({ clusterId }: { clusterId?: string }) =>
Inventory for {clusterId}
})); +import { ClusterDetailPage } from "./ClusterDetail"; + +afterEach(() => { route.clusterId = "east"; route.tab = "overview"; setCurrentCluster("local"); }); +describe("ClusterDetailPage", () => { + it("uses its route registration independently of remembered selection", async () => { + setCurrentCluster("west"); + server.use(http.get("/clusters", () => HttpResponse.json({ items: [{ name: "east", displayName: "East", phase: "Healthy", canViewInventory: true }] }))); + route.tab = "nodes"; + renderWithQuery(); + expect(await screen.findByRole("heading", { name: "East", level: 1 })).toBeInTheDocument(); + expect(screen.getByText("Inventory for east")).toBeInTheDocument(); + }); + + it("does not mount inventory for a registry entry without its explicit capability", async () => { + server.use(http.get("/clusters", () => HttpResponse.json({ items: [{ name: "east", displayName: "East", phase: "Healthy", canViewInventory: false }] }))); + route.tab = "nodes"; + renderWithQuery(); + expect(await screen.findByText(/permission to view node inventory/)).toBeInTheDocument(); + expect(screen.queryByText("Inventory for east")).not.toBeInTheDocument(); + }); + + it("never invents a local registration on an empty standalone installation", async () => { + route.clusterId = "local"; + route.tab = "configuration"; + const destinations = vi.fn(() => HttpResponse.json({ items: [] })); + server.use(http.get("/admin/installation", () => HttpResponse.json({ standalone: true, localCluster: false })), http.get("/clusters", () => HttpResponse.json({ items: [] })), http.get("/backup-destinations", destinations)); + renderWithQuery(); + expect(await screen.findByText("Cluster unavailable")).toBeInTheDocument(); + expect(destinations).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Download kubeconfig" })).not.toBeInTheDocument(); + }); + + it("shows a retryable registry error instead of treating a failed registry as an absent cluster", async () => { + server.use(http.get("/clusters", () => HttpResponse.text("unavailable", { status: 503 }))); + renderWithQuery(); + expect(await screen.findByText(/Couldn't load registered clusters/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(screen.queryByText("Cluster unavailable")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/ClusterDetail.tsx b/web/src/pages/ClusterDetail.tsx new file mode 100644 index 000000000..6fe0aa0a7 --- /dev/null +++ b/web/src/pages/ClusterDetail.tsx @@ -0,0 +1,69 @@ +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Tab, Tabs } from "@heroui/react"; +import { defineMessages } from "react-intl"; +import { Clusters } from "@/lib/endpoints"; +import { APIError } from "@/lib/api"; +import { useInstallation } from "@/lib/useInstallation"; +import { useOptionalIntl } from "@/lib/i18n/format"; +import { formatRelative } from "@/lib/utils"; +import type { ClusterRegistry } from "@/types"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { ErrorCard } from "@/components/ui/ErrorCard"; +import { LoadingCard } from "@/components/ui/LoadingCard"; +import { NodeInventory } from "@/components/nodes/NodeInventory"; +import { ClusterConfiguration } from "@/components/clusters/ClusterConfiguration"; +import { ClusterSection } from "@/components/clusters/ClusterSection"; + +const tabs = defineMessages({ + overview: { id: "web.cluster_detail.overview", defaultMessage: "Overview", description: "Cluster detail overview tab" }, + nodes: { id: "web.cluster_detail.nodes", defaultMessage: "Nodes", description: "Cluster detail nodes tab" }, + configuration: { id: "web.cluster_detail.configuration", defaultMessage: "Configuration", description: "Cluster detail configuration tab" }, +}); +type ClusterTab = keyof typeof tabs; +const unauthorized = (error: unknown) => error instanceof APIError && error.status === 401; + +export function ClusterDetailPage() { + const { clusterId } = useParams({ from: "/app-layout/clusters/$clusterId" }); + const { tab = "overview" } = useSearch({ from: "/app-layout/clusters/$clusterId" }); + const intl = useOptionalIntl(); + const navigate = useNavigate(); + const installation = useInstallation(); + const registry = useQuery({ queryKey: ["clusters"], queryFn: () => Clusters.list(), refetchInterval: 30_000 }); + if (installation.isPending || registry.isPending) return ; + const installationFailure = void installation.refetch()} />; + if (installation.isError && (!installation.data || unauthorized(installation.error))) return installationFailure; + const registryFailure = void registry.refetch()} />; + if (registry.isError && (!registry.data || unauthorized(registry.error))) return registryFailure; + const cluster = registry.data?.items.find((item) => item.name === clusterId && (item.name !== "local" || installation.data?.localCluster)); + if (!cluster) return
; + const selected = tab in tabs ? tab as ClusterTab : "overview"; + const clusterName = cluster.displayName || cluster.name; + return
+ {installation.isError && installationFailure} + {registry.isError && registryFailure} + void navigate({ to: "/servers", search: { cluster: cluster.name } })}>{intl.formatMessage({ id: "web.routes.clusters.view_servers", defaultMessage: "View servers", description: "Clusters: button on a cluster card that opens the server list for that cluster; keep it short; its screen-reader name also includes the cluster name" })}} /> + void navigate({ to: "/clusters/$clusterId", params: { clusterId }, search: { tab: String(key) as ClusterTab } })}> + {(Object.keys(tabs) as ClusterTab[]).map((key) => {intl.formatMessage(tabs[key])})} + + {selected === "overview" && } + {selected === "nodes" && (cluster.canViewInventory === true ?

{intl.formatMessage({ id: "web.cluster_detail.fixed_cluster", defaultMessage: "Cluster: {name}", description: "Fixed cluster scope label above embedded nodes" }, { name: clusterName })}

: )} + {selected === "configuration" && } +
; +} + +function ClusterOverview({ cluster }: { cluster: ClusterRegistry }) { + const intl = useOptionalIntl(); + const navigate = useNavigate(); + const empty = intl.formatMessage({ id: "web.common.empty_value", defaultMessage: "—", description: "Shown in place of a value that is not set" }); + const phase = cluster.phase === "Healthy" ? intl.formatMessage({ id: "web.routes.clusters.phase_healthy", defaultMessage: "Healthy", description: "Clusters: API status label for a cluster the panel reports as healthy" }) : cluster.phase === "Unhealthy" ? intl.formatMessage({ id: "web.routes.clusters.phase_unhealthy", defaultMessage: "Unhealthy", description: "Clusters: API status label for a cluster the panel reports as unhealthy" }) : cluster.phase || intl.formatMessage({ id: "web.routes.clusters.phase_unknown", defaultMessage: "Unknown", description: "Clusters: API status shown when the cluster reports no phase" }); + return
+
{intl.formatMessage({ id: "web.cluster_detail.registration_id", defaultMessage: "Registration ID", description: "Cluster registration identifier label" })}
{cluster.name}
{intl.formatMessage({ id: "web.cluster_detail.kubernetes_version", defaultMessage: "Kubernetes version", description: "Cluster Kubernetes version label" })}
{cluster.serverVersion || empty}
+ {cluster.name === "local" &&

{intl.formatMessage({ id: "web.routes.clusters.central_cluster", defaultMessage: "Central cluster", description: "Clusters: note on the cluster card of the built-in local cluster that the panel itself runs in" })}

} + {cluster.canViewInventory === true && } +
+

{phase}

+ {cluster.message &&

{cluster.message}

} +
{intl.formatMessage({ id: "web.routes.clusters.last_checked", defaultMessage: "Last checked", description: "Clusters: label before the time of the last health check on a cluster card; the relative time follows it" })}
{cluster.lastCheckTime ? formatRelative(cluster.lastCheckTime, intl) : empty}
+
; +} diff --git a/web/src/pages/Nodes.tsx b/web/src/pages/Nodes.tsx new file mode 100644 index 000000000..4fde366ab --- /dev/null +++ b/web/src/pages/Nodes.tsx @@ -0,0 +1,7 @@ +import { NodeInventory } from "@/components/nodes/NodeInventory"; +import { useFleetLocation } from "@/lib/fleet"; + +export function NodesPage() { + const [clusterId, onClusterChange] = useFleetLocation(); + return ; +} diff --git a/web/src/router/tree.tsx b/web/src/router/tree.tsx index 61c946204..11f9f354b 100644 --- a/web/src/router/tree.tsx +++ b/web/src/router/tree.tsx @@ -10,6 +10,8 @@ import { ServersPage } from "@/routes/Servers"; import { ServerDetailPage } from "@/routes/ServerDetail"; import { ModulesPage } from "@/routes/Modules"; import { ClusterPage } from "@/routes/Cluster"; +import { NodesPage } from "@/pages/Nodes"; +import { ClusterDetailPage } from "@/pages/ClusterDetail"; import { ClustersPage } from "@/routes/Clusters"; import { UsersPage } from "@/routes/Users"; import { AdminSettingsPage } from "@/routes/AdminSettings"; @@ -78,7 +80,7 @@ const createServerRoute = new Route({ // /servers/new?template=. validateSearch: (search: Record): { template?: string; cluster?: string; ns?: string } => { for (const key of ["cluster", "ns"] as const) { - if (search[key] !== undefined && (typeof search[key] !== "string" || !search[key])) throw new Error(`Invalid location ${key}`); + if (search[key] !== undefined && (typeof search[key] !== "string" || !search[key])) throw new Error(`Invalid placement ${key}`); } return { template: typeof search.template === "string" ? search.template : undefined, cluster: search.cluster as string | undefined, ns: search.ns as string | undefined }; }, @@ -93,11 +95,25 @@ const modulesRoute = new Route({ const clusterRoute = new Route({ getParentRoute: () => appLayoutRoute, path: "/cluster", - component: () => ( - - - - ), + component: ClusterPage, +}); + +const nodesRoute = new Route({ + getParentRoute: () => appLayoutRoute, + path: "/nodes", + component: NodesPage, + validateSearch: (search: Record): { cluster?: string } => ({ + cluster: typeof search.cluster === "string" && search.cluster !== "" ? search.cluster : undefined, + }), +}); + +const clusterDetailRoute = new Route({ + getParentRoute: () => appLayoutRoute, + path: "/clusters/$clusterId", + component: ClusterDetailPage, + validateSearch: (search: Record): { tab?: "overview" | "nodes" | "configuration" } => ({ + tab: search.tab === "nodes" || search.tab === "configuration" ? search.tab : "overview", + }), }); const clustersRoute = new Route({ @@ -192,7 +208,9 @@ export const routeTree = rootRoute.addChildren([ serverDetailRoute, modulesRoute, clusterRoute, + nodesRoute, clustersRoute, + clusterDetailRoute, usersRoute, adminRoute, themeSettingsRoute, diff --git a/web/src/routes/AdminSettings.test.tsx b/web/src/routes/AdminSettings.test.tsx index 0985c4932..078736619 100644 --- a/web/src/routes/AdminSettings.test.tsx +++ b/web/src/routes/AdminSettings.test.tsx @@ -209,7 +209,6 @@ describe("AdminSettingsPage", () => { for (const label of [ "General", "Authentication", - "Backup destinations", "Module sources", "Notifications", "Telemetry", @@ -218,6 +217,7 @@ describe("AdminSettingsPage", () => { ]) { expect(screen.getByRole("button", { name: new RegExp(label, "i") })).toBeInTheDocument(); } + expect(screen.queryByRole("button", { name: "Backup destinations" })).not.toBeInTheDocument(); }); it("shows loading state when config is loading", async () => { @@ -543,62 +543,6 @@ describe("AdminSettingsPage", () => { expect(await screen.findByText("Configured")).toBeInTheDocument(); }); - it("navigates to Backup Destinations section", async () => { - server.use( - http.get("/admin/config", () => - HttpResponse.json({}), - ), - // BackupDestinations.list() hits /backup-destinations, not the - // /admin-prefixed path — that prefix is only used for the auth - // provider / mod registry secret write endpoints. - http.get("/backup-destinations", () => - HttpResponse.json({ - items: [ - { - name: "primary-backup", - url: "s3:s3.example.com/bucket", - hasPassword: true, - createdAt: "2026-01-01T00:00:00Z", - }, - ], - }), - ), - ); - renderWithQuery(); - await userEvent.click(screen.getByRole("button", { name: /Backup destinations/i })); - expect(await screen.findByText("primary-backup")).toBeInTheDocument(); - }); - - it("shows error when backup destinations fail to load", async () => { - server.use( - http.get("/admin/config", () => - HttpResponse.json({}), - ), - http.get("/backup-destinations", () => - new HttpResponse("Server error", { status: 500 }), - ), - ); - renderWithQuery(); - await userEvent.click(screen.getByRole("button", { name: /Backup destinations/i })); - expect(await screen.findByText(/error/i)).toBeInTheDocument(); - }); - - it("shows empty state for backup destinations", async () => { - server.use( - http.get("/admin/config", () => - HttpResponse.json({}), - ), - http.get("/backup-destinations", () => - HttpResponse.json({ items: [] }), - ), - ); - renderWithQuery(); - await userEvent.click(screen.getByRole("button", { name: /Backup destinations/i })); - expect( - await screen.findByText(/No backup destinations configured/i), - ).toBeInTheDocument(); - }); - it("disables last enabled auth provider toggle", async () => { server.use( http.get("/admin/config", () => diff --git a/web/src/routes/AdminSettings.tsx b/web/src/routes/AdminSettings.tsx index 0add83339..2cce05e53 100644 --- a/web/src/routes/AdminSettings.tsx +++ b/web/src/routes/AdminSettings.tsx @@ -2,7 +2,6 @@ import { useInstallation } from "@/lib/useInstallation"; import { useState, useEffect, useRef, type ChangeEvent, type ComponentType, type ReactNode, type SetStateAction } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - Archive, Bell, BellRing, CircleHelp, @@ -43,15 +42,12 @@ import { RemovableGroupChip } from "@/components/ui/RemovableGroupChip"; import { ProvenanceBadge } from "@/components/ui/ProvenanceBadge"; import { SlackIcon } from "@/components/ui/SlackIcon"; import { SettingsNav, type SettingsSectionKey } from "@/components/ui/SettingsNav"; -import { cn, formatRelative } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { errorTextIntl } from "@/lib/errors"; import { Telemetry, type TelemetryInfo } from "@/lib/api"; import { TELEMETRY_STATEMENT_URL } from "@/lib/links"; -import { Auth, AuthProviders, createResourceClient, Cluster, Clusters, ModRegistries, Notifications, type SinkSecretBody } from "@/lib/endpoints"; -import { useCurrentCluster } from "@/lib/cluster"; -import { ClusterSelector } from "@/components/ClusterSelector"; -import { LoadingCard } from "@/components/ui/LoadingCard"; -import { ErrorCard } from "@/components/ui/ErrorCard"; +import { Auth, AuthProviders, Cluster, ModRegistries, Notifications, type SinkSecretBody } from "@/lib/endpoints"; +import { LegacyBackupSettings } from "@/components/clusters/LegacyBackupSettings"; import { can, useMe } from "@/lib/auth"; import type { ClusterInfo } from "@/types"; import { @@ -77,7 +73,6 @@ import { type SinkKind, type TelemetryCfg, } from "@/lib/config"; -import { ErrorBanner } from "@/components/backups/ErrorBanner"; import { ModuleSourcesPanel } from "@/components/modules/ModuleSourcesPanel"; import { defineMessages, type IntlShape } from "react-intl"; import { useFormat, useOptionalIntl, type FormatApi } from "@/lib/i18n/format"; @@ -127,7 +122,7 @@ export function AdminSettingsPage() {
{/* /admin is gated on config:manage, so every section is reachable. */} - +
{cfg.isLoading && ( @@ -143,7 +138,7 @@ export function AdminSettingsPage() { {cfg.data && section === "general" && } {cfg.data && section === "general" && } {cfg.data && section === "auth" && } - {section === "backups" && } + {section === "backups" && } {section === "modules" && } {cfg.data && section === "modRegistries" && } {cfg.data && section === "notifications" && } @@ -952,173 +947,6 @@ function AddProviderForm({ ); } -function BackupDestSection() { - const intl = useOptionalIntl(); - const installation = useInstallation(); - const selected = useCurrentCluster(); - const registry = useQuery({ - queryKey: ["clusters"], queryFn: () => Clusters.list(), - enabled: installation.data?.standalone === true, - }); - if (installation.isPending) return ; - if (installation.isError) return void installation.refetch()} />; - if (!installation.data.standalone) return ; - const target = registry.data?.items.find((cluster) => cluster.name === selected); - return
- - {registry.isPending ? - : registry.isError ? void registry.refetch()} /> - : target ? - : -

{intl.formatMessage({ id: "web.routes.admin_settings.choose_a_registered_cluster_above_or", defaultMessage: "Choose a registered cluster above, or register one on the Clusters page.", description: "Admin settings: text in the select-a-cluster card asking the user to pick a cluster above or register one on the Clusters page" })}

-
} -
; -} - -function ClusterBackupDestSection({ cluster }: { cluster: string }) { - const intl = useOptionalIntl(); - const qc = useQueryClient(); - const { BackupDestinations } = createResourceClient({ cluster }); - const [adding, setAdding] = useState(false); - const [deleting, setDeleting] = useState(null); - - const list = useQuery({ - queryKey: ["backup-destinations", cluster], - queryFn: () => BackupDestinations.list(), - }); - const remove = useMutation({ - mutationFn: (name: string) => BackupDestinations.remove(name), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["backup-destinations", cluster] }); - setDeleting(null); - }, - }); - - const items = list.data?.items ?? []; - - return ( - setAdding(true)} isDisabled={adding}> - {intl.formatMessage({ id: "web.routes.admin_settings.add_destination", defaultMessage: "Add destination", description: "Admin settings: button that opens the form to add a backup destination, and the heading of that form; keep it short" })} - } - > - {list.isLoading &&
{intl.formatMessage({ id: "web.routes.admin_settings.loading_destinations", defaultMessage: "Loading destinations…", description: "Admin settings: loading text in the backup destinations card while the list is fetched" })}
} - {list.isError && } - {!list.isLoading && items.length === 0 && !adding && ( -
{intl.formatMessage({ id: "web.routes.admin_settings.no_backup_destinations_configured_add", defaultMessage: "No backup destinations configured. Add one to enable snapshots.", description: "Admin settings: empty-state text in the backup destinations card when none exist, asking the user to add one to enable snapshots" })}
- )} - {adding && setAdding(false)} />} -
    - {items.map((d) => ( -
  • -
    - -
    -
    -
    {d.name}
    -
    {d.url}
    -
    - - {d.hasPassword ? intl.formatMessage({ id: "web.routes.admin_settings.configured_lower", defaultMessage: "configured", description: "Admin settings: badge on a backup destination whose password is stored" }) : intl.formatMessage({ id: "web.routes.admin_settings.no_password", defaultMessage: "no-password", description: "Admin settings: badge on a backup destination with no stored password" })} - - {d.createdAt && ( - {formatRelative(d.createdAt, intl)} - )} - -
  • - ))} -
- {remove.error && } - { if (!open) setDeleting(null); }} - title={intl.formatMessage({ id: "web.routes.admin_settings.delete_backup_destination", defaultMessage: "Delete backup destination?", description: "Admin settings: title of the confirmation dialog that asks whether to delete a backup destination" })} - description={ - <> -

{intl.formatMessage({ id: "web.routes.admin_settings.the_repository_at_this_destination_will", defaultMessage: "The repository at this destination will", description: "Admin settings: opening words of the delete dialog text, followed by the emphasised word not and the rest of the sentence" })} {intl.formatMessage({ id: "web.routes.admin_settings.not", defaultMessage: "not", description: "Admin settings: the word not, shown in bold in the delete dialog text to stress the repository is not erased" })} {intl.formatMessage({ id: "web.routes.admin_settings.be_erased_only_the_credentials", defaultMessage: "be erased — only the credentials Gameplane uses to reach it. Existing backups remain intact; new backups against this destination will fail until it's re-added.", description: "Admin settings: end of the delete dialog text: only Gameplane's credentials for the destination are removed, existing backups stay and new backups fail" })}

- {deleting && ( -

{intl.formatMessage({ id: "web.routes.admin_settings.type", defaultMessage: "Type", description: "Admin settings: first word of the line in the delete dialog asking the user to type the destination name to confirm" })} {deleting} {intl.formatMessage({ id: "web.routes.admin_settings.to_confirm", defaultMessage: "to confirm.", description: "Admin settings: last words of the line asking the user to type the destination name to confirm; the name is shown in monospace before them" })}

- )} - - } - confirmPhrase={deleting ?? undefined} - confirmLabel={intl.formatMessage({ id: "web.routes.admin_settings.delete", defaultMessage: "Delete", description: "Admin settings: confirm button of the delete backup destination dialog; keep it short" })} - destructive - busy={remove.isPending} - onConfirm={() => deleting && remove.mutate(deleting)} - /> -
- ); -} - -function NewDestinationForm({ cluster, onClose }: { cluster: string; onClose: () => void }) { - const intl = useOptionalIntl(); - const qc = useQueryClient(); - const { BackupDestinations } = createResourceClient({ cluster }); - const [form, setForm] = useState({ name: "", url: "", password: "" }); - const create = useMutation({ - mutationFn: () => BackupDestinations.upsert(form), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["backup-destinations", cluster] }); - onClose(); - }, - }); - const valid = - /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/.test(form.name) && - form.url.length > 0 && - form.password.length > 0; - return ( -
-
{intl.formatMessage({ id: "web.routes.admin_settings.add_destination", defaultMessage: "Add destination", description: "Admin settings: button that opens the form to add a backup destination, and the heading of that form; keep it short" })}
-

{intl.formatMessage({ id: "web.routes.admin_settings.restic_url_hint", defaultMessage: "Stored as a Secret labelled {secret}. Restic URL formats: {s3}, {b2}, {azure}, etc.", description: "Admin settings: hint under the backup destination form; {secret} is the Secret label, {s3}, {b2} and {azure} are example Restic URL formats" }, { secret: {intl.formatMessage({ id: "web.routes.admin_settings.gameplane_local_backup_destination_true", defaultMessage: "gameplane.local/backup-destination=true", description: "Admin settings: the Secret label example in the backup destination hint" })}, s3: {intl.formatMessage({ id: "web.routes.admin_settings.s3_host_bucket", defaultMessage: "s3:host/bucket", description: "Admin settings: example Restic URL format for S3" })}, b2: {intl.formatMessage({ id: "web.routes.admin_settings.b2_bucket", defaultMessage: "b2:bucket", description: "Admin settings: example Restic URL format for Backblaze B2" })}, azure: {intl.formatMessage({ id: "web.routes.admin_settings.azure_account_container", defaultMessage: "azure:account/container", description: "Admin settings: example Restic URL format for Azure" })} })}

-
- - setForm({ ...form, name: e.target.value })} - /> - - - setForm({ ...form, url: e.target.value })} - /> - - - setForm({ ...form, password: e.target.value })} - /> - -
- {create.error && } -
- - -
-
- ); -} - const defaultModRegistries: ModRegistriesCfg = { registries: [] }; // The managed-Secret name the API derives for a provider's key (mirrors diff --git a/web/src/routes/AdminSettings_sections.test.tsx b/web/src/routes/AdminSettings_sections.test.tsx index caab2177e..7b05c3ec2 100644 --- a/web/src/routes/AdminSettings_sections.test.tsx +++ b/web/src/routes/AdminSettings_sections.test.tsx @@ -352,29 +352,6 @@ describe("AdminSettings sections", () => { expect(await screen.findByRole("switch", { name: /Disable sink/i })).toBeInTheDocument(); }); - it("lists backup destinations and opens the add form", async () => { - renderWithQuery(); - await gotoSection(/Backup destinations/i); - // The default handler returns one destination ("default"). - await userEvent.click(await screen.findByRole("button", { name: /Add destination/i })); - const nameField = await screen.findByPlaceholderText("gameplane-backup-repo"); - await userEvent.type(nameField, "repo1"); - await userEvent.type(screen.getByPlaceholderText(/s3:s3.example.com/i), "s3:host/bucket"); - await userEvent.type(screen.getByPlaceholderText(/passphrase/i), "a-strong-passphrase"); - const save = screen.getByRole("button", { name: /Save destination/i }); - await waitFor(() => expect(save).toBeEnabled()); - await userEvent.click(save); - // On success the form closes; the Add button returns. - expect(await screen.findByRole("button", { name: /Add destination/i })).toBeInTheDocument(); - }); - - it("prompts before deleting a backup destination", async () => { - renderWithQuery(); - await gotoSection(/Backup destinations/i); - await userEvent.click(await screen.findByRole("button", { name: /Delete default/i })); - expect(await screen.findByText(/Delete backup destination\?/i)).toBeInTheDocument(); - }); - it("renders the About section with the license", async () => { renderWithQuery(); await gotoSection(/About/i); diff --git a/web/src/routes/Backups.tsx b/web/src/routes/Backups.tsx index c57987bbc..a29bfc6bf 100644 --- a/web/src/routes/Backups.tsx +++ b/web/src/routes/Backups.tsx @@ -85,7 +85,7 @@ export function BackupsPage() {
setBackupNow(true)}> {intl.formatMessage({ id: "web.routes.backups.back_up_now", defaultMessage: "Back up now", description: "Backups: button that opens the dialog for a one-off snapshot, and the heading of that dialog; keep it short" })} diff --git a/web/src/routes/Cluster.test.tsx b/web/src/routes/Cluster.test.tsx index a83f8e6fd..452d93337 100644 --- a/web/src/routes/Cluster.test.tsx +++ b/web/src/routes/Cluster.test.tsx @@ -1,579 +1,37 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; import { http, HttpResponse } from "msw"; -import { act, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { server } from "@/test/server"; import { renderWithQuery } from "@/test/render"; -import { makeClusterInfo, makeClusterView, makeUser } from "@/test/factories"; -import { ClusterPage } from "./Cluster"; import { setCurrentCluster } from "@/lib/cluster"; +import { ClusterPage } from "./Cluster"; -describe("ClusterPage", () => { - beforeEach(() => setCurrentCluster("local")); - afterEach(() => setCurrentCluster("local")); - - it("does not paint a late local response over selected remote inventory", async () => { - let releaseLocal: () => void = () => {}; - let localRequested = false; - const localResponse = new Promise((resolve) => { releaseLocal = resolve; }); - server.use(http.get("/cluster", async ({ request }) => { - const remote = new URL(request.url).searchParams.get("cluster") === "remote-demo"; - if (!remote) { localRequested = true; await localResponse; } - return HttpResponse.json(makeClusterView({ name: remote ? "remote-demo" : "local", nodes: [{ name: remote ? "remote-node" : "local-node", status: "Ready" }] })); - })); - const { client } = renderWithQuery(); - await waitFor(() => expect(localRequested).toBe(true)); - act(() => setCurrentCluster("remote-demo")); - expect(await screen.findByText("remote-node")).toBeInTheDocument(); - await act(async () => { releaseLocal(); await localResponse; }); - expect(screen.queryByText("local-node")).not.toBeInTheDocument(); - expect(client.getQueryData(["cluster", "remote-demo"])).toMatchObject({ name: "remote-demo" }); - }); - - it("discards local join results and suppresses local settings immediately on remote selection", async () => { - server.use(http.post("/cluster/nodes:join", () => HttpResponse.json({ command: "local-only-join-command", expiresAt: "later" }))); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: /add node/i })); - expect(await screen.findByText("local-only-join-command")).toBeInTheDocument(); - act(() => setCurrentCluster("remote-demo")); - expect(screen.queryByText("local-only-join-command")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: /add node/i })).toBeDisabled(); - expect(screen.getByRole("button", { name: /download kubeconfig/i })).toBeDisabled(); - expect(screen.queryByText("Game data storage class")).not.toBeInTheDocument(); - }); - it("renders node cards from /cluster", async () => { - renderWithQuery(); - await screen.findByText("node-1"); - expect(screen.getByText(/gameplane-prod/)).toBeInTheDocument(); - expect(screen.getByText(/v1\.31\.0/)).toBeInTheDocument(); - }); - - it("shows empty state when no nodes", async () => { - server.use( - http.get("/cluster", () => HttpResponse.json(makeClusterView({ nodes: [] }))), - ); - renderWithQuery(); - await waitFor(() => { - expect(screen.getByText(/No node data yet/)).toBeInTheDocument(); - }); - }); - - // Regression: a node whose `used` is absent (no metrics-server) used to - // render as a 0% bar, indistinguishable from a genuinely idle node. - it("shows unknown (—) for CPU/memory when a node has no `used` reading", async () => { - server.use( - http.get("/cluster", () => - HttpResponse.json( - makeClusterView({ - nodes: [ - { - name: "no-metrics-node", - status: "Ready", - cpu: { capacity: 8 }, - memory: { capacity: 16_000_000_000 }, - }, - ], - }), - ), - ), - ); - renderWithQuery(); - await screen.findByText("no-metrics-node"); - // Two meters (CPU, Memory) both read unknown. - expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(2); - expect(screen.queryByText("0%")).not.toBeInTheDocument(); - }); - - it("falls back gracefully on API error", async () => { - server.use(http.get("/cluster", () => HttpResponse.error())); - renderWithQuery(); - await waitFor(() => { - expect(screen.getByText(/Couldn't load node inventory for local/)).toBeInTheDocument(); - }); - expect(screen.queryByText(/No node data yet/)).not.toBeInTheDocument(); - }); - - it("Add node shows the join command on success", async () => { - server.use( - http.post("/cluster/nodes:join", () => - HttpResponse.json({ - command: "kubeadm join api.test:6443 --token abcdef.0123456789abcdef --discovery-token-ca-cert-hash sha256:deadbeef", - token: "abcdef.0123456789abcdef", - caCertHash: "sha256:deadbeef", - endpoint: "api.test:6443", - expiresAt: "2026-06-15T00:00:00Z", - }), - ), - ); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: /add node/i })); - expect(await screen.findByText(/kubeadm join api\.test:6443/)).toBeInTheDocument(); - }); - - it("Add node surfaces a clear message when clusterOps is disabled (501)", async () => { - server.use( - http.post("/cluster/nodes:join", () => new HttpResponse("not enabled", { status: 501 })), - ); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: /add node/i })); - expect(await screen.findByText(/aren't enabled/i)).toBeInTheDocument(); - }); - - it("disables both ops buttons with a hint when /cluster/info reports clusterOps off", async () => { - server.use( - http.get("/cluster/info", () => - HttpResponse.json(makeClusterInfo({ clusterOps: false })), - ), - ); - renderWithQuery(); - await waitFor(() => { - expect(screen.getByRole("button", { name: /add node/i })).toBeDisabled(); - }); - expect(screen.getByRole("button", { name: /download kubeconfig/i })).toBeDisabled(); - expect(screen.getByText(/clusterOps\.enabled/)).toBeInTheDocument(); - }); - - it("keeps the ops buttons active when clusterOps is on", async () => { - renderWithQuery(); - // Default handler reports clusterOps: true — no hint, buttons live. - await screen.findByText("node-1"); - expect(screen.getByRole("button", { name: /add node/i })).toBeEnabled(); - expect(screen.getByRole("button", { name: /download kubeconfig/i })).toBeEnabled(); - expect(screen.queryByText(/clusterOps\.enabled/)).not.toBeInTheDocument(); - }); - - it("keeps the ops buttons active when the info fetch fails (older API)", async () => { - server.use(http.get("/cluster/info", () => HttpResponse.error())); - renderWithQuery(); - await screen.findByText("node-1"); - expect(screen.getByRole("button", { name: /add node/i })).toBeEnabled(); - expect(screen.getByRole("button", { name: /download kubeconfig/i })).toBeEnabled(); - }); - - it("shows error when Add node fails with 403", async () => { - server.use( - http.post("/cluster/nodes:join", () => - new HttpResponse("forbidden", { status: 403 }) - ), - ); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: /add node/i })); - expect(await screen.findByText(/don't have permission/i)).toBeInTheDocument(); - }); - - it("shows generic error when Add node fails with 500", async () => { - server.use( - http.post("/cluster/nodes:join", () => - new HttpResponse("server error", { status: 500 }) - ), - ); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: /add node/i })); - expect(await screen.findByText(/operation failed/i)).toBeInTheDocument(); - }); - - it("downloads kubeconfig blob", async () => { - const createObjectURLMock = vi.fn(() => "blob:mock-url"); - const revokeObjectURLMock = vi.fn(); - const clickMock = vi.fn(); - - globalThis.URL.createObjectURL = createObjectURLMock; - globalThis.URL.revokeObjectURL = revokeObjectURLMock; - - server.use( - http.post("/cluster/kubeconfig", () => - // A raw `Blob` body throws inside msw's Response construction on this - // environment's jsdom (its Blob lacks `.stream()`, which msw's - // Response proxy calls while building the mocked Response) — use a - // string body instead. The client's `res.blob()` still round-trips it - // into a real Blob regardless of the original body shape (same fix - // as AuditLog.test.tsx's CSV export test). - new HttpResponse("kubeconfig content", { headers: { "Content-Type": "text/plain" } }) - ), - ); - - renderWithQuery(); - const downloadBtn = await screen.findByRole("button", { name: /download kubeconfig/i }); - - // Mock createElement and click - const originalCreateElement = document.createElement.bind(document); - document.createElement = vi.fn((tag) => { - if (tag === "a") { - const el = originalCreateElement("a"); - el.click = clickMock; - return el; - } - return originalCreateElement(tag); - }); - - await userEvent.click(downloadBtn); - await waitFor(() => { - expect(clickMock).toHaveBeenCalled(); - }); - - // Cleanup - document.createElement = originalCreateElement; - }); - - it("shows error when kubeconfig download fails", async () => { - server.use( - http.post("/cluster/kubeconfig", () => - new HttpResponse("forbidden", { status: 403 }) - ), - ); - renderWithQuery(); - const downloadBtn = await screen.findByRole("button", { name: /download kubeconfig/i }); - await userEvent.click(downloadBtn); - expect(await screen.findByText(/don't have permission/i, {}, { timeout: 10000 })).toBeInTheDocument(); - }); - - it("shows generic error when kubeconfig download fails with 500", async () => { - server.use( - http.get("/cluster/kubeconfig", () => - new HttpResponse("internal error", { status: 500 }) - ), - ); - renderWithQuery(); - const downloadBtn = await screen.findByRole("button", { name: /download kubeconfig/i }); - await userEvent.click(downloadBtn); - expect(await screen.findByText(/operation failed/i)).toBeInTheDocument(); - }); - - it("dismisses join info card", async () => { - server.use( - http.post("/cluster/nodes:join", () => - HttpResponse.json({ - command: "kubeadm join api.test:6443 --token abcdef.0123456789abcdef --discovery-token-ca-cert-hash sha256:deadbeef", - token: "abcdef.0123456789abcdef", - caCertHash: "sha256:deadbeef", - endpoint: "api.test:6443", - expiresAt: "2026-06-15T00:00:00Z", - }), - ), - ); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: /add node/i })); - expect(await screen.findByText(/kubeadm join/)).toBeInTheDocument(); - const dismissBtn = screen.getByRole("button", { name: /dismiss/i }); - await userEvent.click(dismissBtn); - await waitFor(() => { - expect(screen.queryByText(/kubeadm join/)).not.toBeInTheDocument(); - }); - }); - - it("copies join command to clipboard", async () => { - const clipboardMock = vi.fn(); - Object.assign(navigator, { - clipboard: { - writeText: clipboardMock, - }, - }); - - server.use( - http.post("/cluster/nodes:join", () => - HttpResponse.json({ - command: "kubeadm join api.test:6443 --token abcdef.0123456789abcdef --discovery-token-ca-cert-hash sha256:deadbeef", - token: "abcdef.0123456789abcdef", - caCertHash: "sha256:deadbeef", - endpoint: "api.test:6443", - expiresAt: "2026-06-15T00:00:00Z", - }), - ), - ); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: /add node/i })); - const copyBtn = await screen.findByRole("button", { name: /copy/i }); - await userEvent.click(copyBtn); - await waitFor(() => { - expect(clipboardMock).toHaveBeenCalledWith(expect.stringContaining("kubeadm join")); - }); - }); - - it("renders node with roles and labels", async () => { - server.use( - http.get("/cluster", () => - HttpResponse.json( - makeClusterView({ - nodes: [ - { - name: "master-1", - status: "Ready", - roles: ["master", "control-plane"], - labels: ["node-type=control", "zone=us-east-1"], - cpu: { used: 2, capacity: 8 }, - memory: { used: 4_000_000_000, capacity: 16_000_000_000 }, - uptime: "45d 3h 22m", - }, - ], - }), - ), - ), - ); - renderWithQuery(); - await screen.findByText("master-1"); - expect(screen.getByText(/master.*control-plane/)).toBeInTheDocument(); - expect(screen.getByText(/node-type=control/)).toBeInTheDocument(); - expect(screen.getByText(/zone=us-east-1/)).toBeInTheDocument(); - }); - - it("renders node without labels", async () => { - server.use( - http.get("/cluster", () => - HttpResponse.json( - makeClusterView({ - nodes: [ - { - name: "worker-1", - status: "Ready", - cpu: { used: 1, capacity: 4 }, - memory: { used: 2_000_000_000, capacity: 8_000_000_000 }, - }, - ], - }), - ), - ), - ); - renderWithQuery(); - await screen.findByText("worker-1"); - // Scope query to the node card to avoid matching the "worker" role badge. - // The card's own wrapper carries "space-y-4" (see NodeCard in Cluster.tsx) - // — "space-y-2" only wraps the inner meters block, which doesn't contain - // the node name, so `.closest(".space-y-2")` never matched. - const nodeCard = screen.getByText("worker-1").closest(".space-y-4"); - expect(nodeCard?.textContent).toMatch(/worker/i); - }); +vi.mock("@tanstack/react-router", () => ({ + Navigate: ({ search }: { search: { cluster?: string } }) =>
redirect:{search.cluster || "all"}
, +})); - it("renders node with missing status as Unknown", async () => { - server.use( - http.get("/cluster", () => - HttpResponse.json( - makeClusterView({ - nodes: [ - { - name: "unknown-node", - status: undefined, - cpu: { capacity: 8 }, - memory: { capacity: 16_000_000_000 }, - }, - ], - }), - ), - ), - ); +describe("legacy cluster bookmark", () => { + it("preserves an authorized remembered cluster", async () => { + setCurrentCluster("east"); + server.use(http.get("/clusters", () => HttpResponse.json({ items: [{ name: "east", canViewInventory: true }] }))); renderWithQuery(); - await screen.findByText("unknown-node"); - expect(screen.getByText(/Unknown/)).toBeInTheDocument(); + expect(await screen.findByText("redirect:east")).toBeInTheDocument(); }); - it("renders node with pending/NotReady status", async () => { + it("opens all nodes when standalone has no local cluster", async () => { + setCurrentCluster("local"); server.use( - http.get("/cluster", () => - HttpResponse.json( - makeClusterView({ - nodes: [ - { - name: "pending-node", - status: "NotReady", - cpu: { used: 0, capacity: 4 }, - memory: { used: 512_000_000, capacity: 8_000_000_000 }, - }, - ], - }), - ), - ), + http.get("/admin/installation", () => HttpResponse.json({ standalone: true, localCluster: false })), + http.get("/clusters", () => HttpResponse.json({ items: [{ name: "east", canViewInventory: true }] })), ); renderWithQuery(); - await screen.findByText("pending-node"); - const badge = screen.getByText(/NotReady/); - expect(badge.className).toContain("danger"); + expect(await screen.findByText("redirect:all")).toBeInTheDocument(); }); - it("renders multiple nodes in grid", async () => { - server.use( - http.get("/cluster", () => - HttpResponse.json( - makeClusterView({ - nodes: [ - { - name: "node-1", - status: "Ready", - cpu: { used: 1, capacity: 4 }, - memory: { used: 2_000_000_000, capacity: 8_000_000_000 }, - }, - { - name: "node-2", - status: "Ready", - cpu: { used: 2, capacity: 8 }, - memory: { used: 4_000_000_000, capacity: 16_000_000_000 }, - }, - ], - }), - ), - ), - ); + it("discards an inaccessible remembered inventory scope", async () => { + setCurrentCluster("east"); + server.use(http.get("/clusters", () => HttpResponse.json({ items: [{ name: "east", canViewInventory: false }] }))); renderWithQuery(); - await screen.findByText("node-1"); - expect(screen.getByText("node-2")).toBeInTheDocument(); - }); - - it("renders subtitle with cluster info when available", async () => { - server.use( - http.get("/cluster", () => - HttpResponse.json( - makeClusterView({ - name: "production", - version: "v1.31.0", - ready: 3, - total: 3, - nodes: [ - { name: "node-1", status: "Ready", cpu: { capacity: 4 }, memory: { capacity: 8_000_000_000 } }, - { name: "node-2", status: "Ready", cpu: { capacity: 4 }, memory: { capacity: 8_000_000_000 } }, - { name: "node-3", status: "Ready", cpu: { capacity: 4 }, memory: { capacity: 8_000_000_000 } }, - ], - }), - ), - ), - ); - renderWithQuery(); - await waitFor(() => { - // The subtitle is one interpolated string ("production · v1.31.0 · - // 3/3 nodes healthy"), so it renders as a single text node — an exact - // string match against just "production" can never succeed. Use a - // partial regex, like the other substring assertions in this file. - expect(screen.getByText(/production/)).toBeInTheDocument(); - }); - expect(screen.getByText(/3\/3 nodes healthy/)).toBeInTheDocument(); - }); - - it("clears error when operations succeed", async () => { - let callCount = 0; - server.use( - http.post("/cluster/nodes:join", () => { - callCount++; - if (callCount === 1) { - return new HttpResponse("error", { status: 500 }); - } - return HttpResponse.json({ - command: "kubeadm join ...", - token: "abc", - caCertHash: "def", - endpoint: "api.test", - expiresAt: "2026-06-15T00:00:00Z", - }); - }), - ); - renderWithQuery(); - const addBtn = await screen.findByRole("button", { name: /add node/i }); - - // First attempt fails - await userEvent.click(addBtn); - expect(await screen.findByText(/operation failed/i)).toBeInTheDocument(); - - // Second attempt succeeds - await userEvent.click(addBtn); - await waitFor(() => { - expect(screen.queryByText(/operation failed/i)).not.toBeInTheDocument(); - expect(screen.getByText(/kubeadm join/)).toBeInTheDocument(); - }); - }); - - describe("Storage section", () => { - it("renders the storage card when installTimeSettings is present with a configured class", async () => { - server.use( - http.get("/admin/config", () => - HttpResponse.json({ - general: { - instanceName: "Gameplane", - externalURL: "https://gameplane.local", - defaultNamespace: "gameplane-games", - }, - installTimeSettings: { - gameDataStorageClass: "fast-nvme", - }, - }), - ), - ); - renderWithQuery(); - // Wait for the page to render and the config to load. - expect(await screen.findByText("Storage")).toBeInTheDocument(); - expect(screen.getByText("Game data storage class")).toBeInTheDocument(); - expect(screen.getByText("fast-nvme")).toBeInTheDocument(); - expect(screen.getByText(/StorageClass for new GameServers/)).toBeInTheDocument(); - }); - - it("renders the storage card with 'Cluster default' when gameDataStorageClass is unset", async () => { - server.use( - http.get("/admin/config", () => - HttpResponse.json({ - general: { - instanceName: "Gameplane", - externalURL: "https://gameplane.local", - defaultNamespace: "gameplane-games", - }, - installTimeSettings: { - gameDataStorageClass: "", - }, - }), - ), - ); - renderWithQuery(); - expect(await screen.findByText("Storage")).toBeInTheDocument(); - expect(screen.getByText("Game data storage class")).toBeInTheDocument(); - // Should render the "Cluster default" pill badge. - expect(screen.getByText("Cluster default")).toBeInTheDocument(); - // Should NOT render the storage class as plain text. - expect(screen.queryByText(/^fast-nvme$/)).not.toBeInTheDocument(); - // Should include the additional hint text about using cluster default. - expect(screen.getByText(/Left unset, so new volumes use the cluster's default StorageClass/)).toBeInTheDocument(); - }); - - it("does not render the storage card when installTimeSettings is absent", async () => { - server.use( - http.get("/admin/config", () => - HttpResponse.json({ - general: { - instanceName: "Gameplane", - externalURL: "https://gameplane.local", - defaultNamespace: "gameplane-games", - }, - }), - ), - ); - renderWithQuery(); - // The page should still render (with node data). - await screen.findByText("node-1"); - // But the storage section should not be present. - expect(screen.queryByText("Storage")).not.toBeInTheDocument(); - expect(screen.queryByText("Game data storage class")).not.toBeInTheDocument(); - }); - - it("does not render storage card and does not fire admin-config query for users without config:read permission", async () => { - const configHandler = vi.fn(() => - HttpResponse.json({ - general: { - instanceName: "Gameplane", - externalURL: "https://gameplane.local", - defaultNamespace: "gameplane-games", - }, - installTimeSettings: { - gameDataStorageClass: "fast-nvme", - }, - }), - ); - server.use( - http.get("/users/me", () => HttpResponse.json(makeUser({ role: "viewer" }))), - http.get("/admin/config", configHandler), - ); - const { client } = renderWithQuery(); - // Wait for the viewer identity to load. - await waitFor(() => expect(client.getQueryData(["me"])).toBeTruthy()); - // Wait for the page to render (the cluster data should load). - await screen.findByText("node-1"); - // The storage section should NOT be present. - expect(screen.queryByText("Storage")).not.toBeInTheDocument(); - expect(screen.queryByText("Game data storage class")).not.toBeInTheDocument(); - // The /admin/config handler should NOT have been called. - expect(configHandler).not.toHaveBeenCalled(); - }); + expect(await screen.findByText("redirect:all")).toBeInTheDocument(); }); }); diff --git a/web/src/routes/Cluster.tsx b/web/src/routes/Cluster.tsx index b9761a786..d03b6b545 100644 --- a/web/src/routes/Cluster.tsx +++ b/web/src/routes/Cluster.tsx @@ -1,338 +1,17 @@ -import { useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { Download, Plus, Server as ServerIcon, AlertCircle } from "lucide-react"; -import { Button, Card, CardContent, Alert } from "@heroui/react"; -import { Meter } from "@/components/ui/Meter"; -import { PageHeader } from "@/components/ui/PageHeader"; -import { formatBytes, formatUptime } from "@/lib/utils"; -import { APIError, api } from "@/lib/api"; -import type { ClusterNode, NodeJoinInfo } from "@/types"; -import { Cluster, Clusters } from "@/lib/endpoints"; -import { useMe, can } from "@/lib/auth"; -import type { AllConfig } from "@/lib/config"; +import { Navigate } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; import { useCurrentCluster } from "@/lib/cluster"; -import { ClusterSelector } from "@/components/ClusterSelector"; -import { ErrorCard } from "@/components/ui/ErrorCard"; -import { LoadingCard } from "@/components/ui/LoadingCard"; +import { Clusters } from "@/lib/endpoints"; import { useInstallation } from "@/lib/useInstallation"; -import { useOptionalIntl } from "@/lib/i18n/format"; -import { defineMessages, type MessageDescriptor } from "react-intl"; - -// Helm key shown as code in the cluster-ops note; a machine value, not copy. -const CLUSTER_OPS_KEY = "clusterOps.enabled"; - -const opErrorMessages = defineMessages({ - notEnabled: { id: "web.routes.cluster.op_not_enabled", defaultMessage: "Cluster operations aren't enabled on this install (set clusterOps.enabled).", description: "Cluster: error shown when the operator has cluster operations turned off (HTTP 501)" }, - forbidden: { id: "web.routes.cluster.op_forbidden", defaultMessage: "You don't have permission to do that.", description: "Cluster: error shown when the user lacks permission for a cluster operation (HTTP 403)" }, - failed: { id: "web.routes.cluster.op_failed", defaultMessage: "That operation failed. Check the API logs.", description: "Cluster: generic error shown when a cluster operation fails" }, -}); - -// opMessage turns a cluster-op error into user copy — 501 means the -// operator hasn't enabled clusterOps. -function opMessage(e: unknown): MessageDescriptor { - if (e instanceof APIError && e.status === 501) return opErrorMessages.notEnabled; - if (e instanceof APIError && e.status === 403) return opErrorMessages.forbidden; - return opErrorMessages.failed; -} - -// Node status is a machine value from the API; Ready/NotReady show as labels, -// anything else as received, and a missing status shows as Unknown. -const nodeStatusMessages = defineMessages({ - ready: { id: "web.routes.cluster.status_ready", defaultMessage: "Ready", description: "Cluster: node status label for a node that is ready" }, - notReady: { id: "web.routes.cluster.status_not_ready", defaultMessage: "NotReady", description: "Cluster: node status label for a node that is not ready" }, - unknown: { id: "web.routes.cluster.status_unknown", defaultMessage: "Unknown", description: "Cluster: node status shown when the node reports none" }, -}); - -function nodeStatusLabel(intl: ReturnType, status: string | undefined): string { - if (!status) return intl.formatMessage(nodeStatusMessages.unknown); - if (status === "Ready") return intl.formatMessage(nodeStatusMessages.ready); - if (status === "NotReady") return intl.formatMessage(nodeStatusMessages.notReady); - return status; -} +import { LoadingCard } from "@/components/ui/LoadingCard"; +/** Preserve old bookmarks without making remembered selection the fleet default. */ export function ClusterPage() { - const intl = useOptionalIntl(); - const clusterId = useCurrentCluster(); + const cluster = useCurrentCluster(); const installation = useInstallation(); - const registry = useQuery({ queryKey: ["clusters"], queryFn: () => Clusters.list(), enabled: installation.data?.standalone === true }); - const available = installation.data?.localCluster || registry.data?.items.some((cluster) => cluster.name === clusterId); - return <>
- {installation.isPending ? - : installation.isError ? void installation.refetch()} /> - : available ? - : registry.isPending ? - : registry.isError ? void registry.refetch()} /> - : -

{intl.formatMessage({ id: "web.routes.cluster.select_a_workload_cluster", defaultMessage: "Select a workload cluster", description: "Cluster: heading of the empty-state card shown when no workload cluster is selected" })}

-

{intl.formatMessage({ id: "web.routes.cluster.choose_a_registered_cluster_to_view_its", defaultMessage: "Choose a registered cluster to view its node inventory. Register workload clusters on the Clusters page.", description: "Cluster: hint under the empty-state heading saying a registered cluster must be picked to see its nodes and that clusters are registered on the Clusters page" })}

-
} - ; -} - -function ClusterInventory({ clusterId }: { clusterId: string }) { - const intl = useOptionalIntl(); - const { data: me } = useMe(); - const canReadConfig = clusterId === "local" && can(me, "config:read"); - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: ["cluster", clusterId], - queryFn: ({ signal }) => Cluster.view(clusterId, signal), - refetchInterval: 15_000, - }); - const { data: info } = useQuery({ - queryKey: ["cluster-info", clusterId], - queryFn: ({ signal }) => Cluster.info(clusterId, signal), - }); - const { data: config } = useQuery({ - queryKey: ["config"], - queryFn: () => api("/admin/config"), - enabled: canReadConfig, - }); - // Only an explicit false disables the buttons — an older API (or a - // failed info fetch) leaves them active, and the 501 opMessage still - // catches the disabled case as a backstop. - const opsDisabled = clusterId !== "local" || info?.clusterOps === false; - const disabledHint = opsDisabled - ? intl.formatMessage({ id: "web.routes.cluster.ops_disabled_hint", defaultMessage: "Cluster operations are disabled on this install.", description: "Cluster: tooltip on the node action buttons when cluster operations are off" }) - : undefined; - const emptyValue = intl.formatMessage({ id: "web.common.empty_value", defaultMessage: "—", description: "Shown in place of a value that is not set" }); - - const nodes = data?.nodes ?? []; - const [joinInfo, setJoinInfo] = useState(null); - const [opError, setOpError] = useState(null); - - const addNode = useMutation({ - mutationFn: () => Cluster.addNode(clusterId), - onSuccess: (info) => { - setOpError(null); - setJoinInfo(info); - }, - onError: (e) => setOpError(intl.formatMessage(opMessage(e))), - }); - - async function downloadKubeconfig() { - setOpError(null); - try { - const blob = await Cluster.kubeconfig(clusterId); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "gameplane-kubeconfig.yaml"; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } catch (e) { - setOpError(intl.formatMessage(opMessage(e))); - } - } - - return ( -
- 0) - ? intl.formatMessage({ id: "web.routes.cluster.summary", defaultMessage: "{name} · {version} · {ready}/{total} nodes healthy", description: "Cluster: page subtitle; {name} is the cluster name, {version} the Kubernetes version, {ready} and {total} the node counts" }, { name: data.name || clusterId, version: data.version || emptyValue, ready: data.ready ?? nodes.filter(n => n.status === "Ready").length, total: data.total ?? nodes.length }) - : intl.formatMessage({ id: "web.routes.cluster.node_inventory_for", defaultMessage: "Node inventory for selected cluster: {clusterId}.", description: "Cluster: page subtitle shown before node data loads; {clusterId} is the cluster id" }, { clusterId }) - } - actions={ -
-
-
- -
-
- -
-
- {opsDisabled && ( -

- {clusterId !== "local" - ? intl.formatMessage({ id: "web.routes.cluster.remote_managed_note", defaultMessage: "Node enrollment and kubeconfig downloads are managed at this remote cluster.", description: "Cluster: note under the action buttons on a remote cluster" }) - : intl.formatMessage({ id: "web.routes.cluster.enable_helm_note", defaultMessage: "Enable {setting} in the Helm values to mint node-join tokens and kubeconfigs.", description: "Cluster: note under the action buttons when operations are disabled; {setting} is the Helm key, shown as code" }, { setting: {CLUSTER_OPS_KEY} })} -

- )} -
- } - /> - - {opError && ( - - - - - {opError} - - )} - - {joinInfo && ( - - -
-
{intl.formatMessage({ id: "web.routes.cluster.join_a_node", defaultMessage: "Join a node", description: "Cluster: heading of the card that shows the command to run on a machine that should join the cluster as a node" })}
- -
-

{intl.formatMessage({ id: "web.routes.cluster.run_this_on_the_machine_you_want_to", defaultMessage: "Run this on the machine you want to join (token expires {expiresAt}):", description: "Cluster: sentence with expiresAt in p" }, { expiresAt: joinInfo.expiresAt })}

-
- - {joinInfo.command} - - -
-
-
- )} - - {isLoading && } - {error && void refetch()} />} - {!isLoading && !error && nodes.length === 0 && ( - - -
- -
-
-
{intl.formatMessage({ id: "web.routes.cluster.no_node_data_yet", defaultMessage: "No node data yet.", description: "Cluster: empty-state heading shown when no node information has arrived for the cluster; the line below it says what to check" })}
-
{intl.formatMessage({ id: "web.routes.cluster.no_nodes_are_reporting_yet_check_the", defaultMessage: "No nodes are reporting yet — check the API and operator connection.", description: "Cluster: hint under the empty-state heading asking the user to check the API and operator connection because no nodes report yet" })}
-
-
-
- )} - - {canReadConfig && config?.installTimeSettings && ( - - -
-

{intl.formatMessage({ id: "web.routes.cluster.storage", defaultMessage: "Storage", description: "Cluster: section heading for the storage settings that are fixed at install time; shown only to users who can read the cluster configuration" })}

-

{intl.formatMessage({ id: "web.routes.cluster.set_at_install_time_via_helm_values_not", defaultMessage: "Set at install time via Helm values — not editable from the dashboard.", description: "Cluster: hint under the storage heading explaining that these values come from Helm values at install time and cannot be edited in the dashboard" })}

-
- -
-
-
-
{intl.formatMessage({ id: "web.routes.cluster.game_data_storage_class", defaultMessage: "Game data storage class", description: "Cluster: label of the setting that names the storage class used for new game server data volumes; storage class is a technical term, usually left untranslated" })}
-

{intl.formatMessage({ id: "web.routes.cluster.storageclass_for_new_gameservers_game", defaultMessage: "StorageClass for new GameServers' game-data volumes — set by the operator.gameDataStorage.storageClassName Helm value and applied by the operator to every new PVC; the API is passed the same value as --game-data-storage-class only to report it here.", description: "Cluster: explanation under the game data storage class label; a Helm value sets it and it applies to new volumes; StorageClass is a technical term, usually left untranslated" })} {config.installTimeSettings.gameDataStorageClass === "" && - intl.formatMessage({ id: "web.routes.cluster.storage_class_unset", defaultMessage: "Left unset, so new volumes use the cluster's default StorageClass.", description: "Cluster: note under the StorageClass description when no StorageClass is set" })} -

-
- {config.installTimeSettings.gameDataStorageClass ? ( -
- {config.installTimeSettings.gameDataStorageClass} -
- ) : ( -
{intl.formatMessage({ id: "web.routes.cluster.cluster_default", defaultMessage: "Cluster default", description: "Cluster: badge shown instead of a storage class name when none is set, meaning new volumes use the cluster's default; keep it short" })}
- )} -
-
-
-
- )} - -
- {!error && nodes.map((n) => )} -
-
- ); -} - -function NodeCard({ node }: { node: ClusterNode }) { - const intl = useOptionalIntl(); - const cpuKnown = node.cpu?.used !== undefined; - const memKnown = node.memory?.used !== undefined; - const cpuPct = pct(node.cpu?.used, node.cpu?.capacity); - const memPct = pct(node.memory?.used, node.memory?.capacity); - const ready = node.status === "Ready"; - const uptime = node.uptime ?? formatUptime(node.startedAt, intl); - const emptyValue = intl.formatMessage({ id: "web.common.empty_value", defaultMessage: "—", description: "Shown in place of a value that is not set" }); - return ( - - -
-
-
- -
-
-
{node.name}
-
- {node.roles?.join(", ") || intl.formatMessage({ id: "web.routes.cluster.role_worker", defaultMessage: "worker", description: "Cluster: role label shown for a node that reports no Kubernetes roles" })} -
-
-
- - {intl.formatMessage({ id: "web.routes.cluster.node_status_badge", defaultMessage: "● {status}", description: "Cluster: node status badge; {status} is the node status, or Unknown when none is reported" }, { status: nodeStatusLabel(intl, node.status) })} - -
- -
- - - -
- -
- - -
- - {node.labels && node.labels.length > 0 && ( -
- {node.labels.map((l) => ( - - {l} - - ))} -
- )} -
-
- ); -} - -function Stat({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function pct(u?: number, cap?: number): number { - if (u === undefined || !cap) return 0; - return (u / cap) * 100; + const registry = useQuery({ queryKey: ["clusters"], queryFn: () => Clusters.list() }); + if (installation.isPending || registry.isPending) return ; + const available = registry.data?.items.some((item) => item.name === cluster && item.canViewInventory === true) + && (cluster !== "local" || installation.data?.localCluster === true); + return ; } diff --git a/web/src/routes/Clusters.test.tsx b/web/src/routes/Clusters.test.tsx index 3cb82e652..bab2888b9 100644 --- a/web/src/routes/Clusters.test.tsx +++ b/web/src/routes/Clusters.test.tsx @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import { server } from "@/test/server"; import { makeClient, renderWithQuery } from "@/test/render"; import { getCurrentCluster, setCurrentCluster } from "@/lib/cluster"; +import { makeUser } from "@/test/factories"; const navigate = vi.hoisted(() => vi.fn()); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigate })); @@ -47,8 +48,24 @@ describe("ClustersPage", () => { server.use(http.get("/clusters", () => HttpResponse.json({ items: [{ ...registrations[1], canViewInventory: true }] }))); renderWithQuery(); await userEvent.click(await screen.findByRole("button", { name: "View nodes in East" })); - await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: "/cluster" })); - expect(getCurrentCluster()).toBe("east"); + await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: "/nodes", search: { cluster: "east" } })); + expect(getCurrentCluster()).toBe("local"); + }); + + it("opens details without changing the infrastructure selection or offering directory removal", async () => { + server.use(http.get("/clusters", () => HttpResponse.json({ items: registrations }))); + renderWithQuery(); + await userEvent.click(await screen.findByRole("button", { name: "Open cluster East" })); + expect(navigate).toHaveBeenCalledWith({ to: "/clusters/$clusterId", params: { clusterId: "east" }, search: { tab: "overview" } }); + expect(getCurrentCluster()).toBe("local"); + expect(screen.queryByRole("button", { name: /Remove cluster/ })).not.toBeInTheDocument(); + }); + + it("admits an authorized destination-only registration without exposing node inventory", async () => { + server.use(http.get("/users/me", () => HttpResponse.json(makeUser({ permissions: {}, permissionsByCluster: { east: { "gameplane-games": ["destinations:read"] } } }))), http.get("/clusters", () => HttpResponse.json({ items: [registrations[1]] }))); + renderWithQuery(); + expect(await screen.findByRole("button", { name: "Open cluster East" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "View nodes in East" })).not.toBeInTheDocument(); }); it("does not invent a local registration for empty or failed registry responses", async () => { diff --git a/web/src/routes/Clusters.tsx b/web/src/routes/Clusters.tsx index 3cf29134c..ba8247986 100644 --- a/web/src/routes/Clusters.tsx +++ b/web/src/routes/Clusters.tsx @@ -1,18 +1,15 @@ import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Button, Card, CardContent } from "@heroui/react"; import { Network } from "lucide-react"; import { PageHeader } from "@/components/ui/PageHeader"; import { ErrorCard } from "@/components/ui/ErrorCard"; import { LoadingCard } from "@/components/ui/LoadingCard"; import { Clusters } from "@/lib/endpoints"; -import { getCurrentCluster, setCurrentCluster, useCurrentCluster } from "@/lib/cluster"; -import { useClusterSelection } from "@/lib/useClusterSelection"; import { useMe, can } from "@/lib/auth"; import { cn, formatRelative } from "@/lib/utils"; import { RegisterClusterDialog } from "@/components/RegisterClusterDialog"; -import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { useOptionalIntl } from "@/lib/i18n/format"; import { defineMessages } from "react-intl"; @@ -31,20 +28,7 @@ function clusterPhaseLabel(intl: ReturnType, phase: stri export function ClustersPage() { const intl = useOptionalIntl(); const { data: me } = useMe(); - const client = useQueryClient(); const [registerOpen, setRegisterOpen] = useState(false); - const [removeTarget, setRemoveTarget] = useState(null); - const remove = useMutation({ - mutationFn: (name: string) => Clusters.remove(name), - onSuccess: async (_data, name) => { - if (getCurrentCluster() === name) setCurrentCluster(""); - setRemoveTarget(null); - await client.invalidateQueries({ queryKey: ["clusters"] }); - await client.invalidateQueries({ queryKey: ["fleet"] }); - }, - }); - const currentCluster = useCurrentCluster(); - const selectCluster = useClusterSelection(); const navigate = useNavigate(); const { data, isLoading, error, refetch } = useQuery({ queryKey: ["clusters"], @@ -52,28 +36,13 @@ export function ClustersPage() { refetchInterval: 30_000, }); const clusters = data?.items ?? []; - const openCluster = async (id: string, destination: "/servers" | "/cluster") => { - if (destination === "/servers") { await navigate({ to: "/servers", search: { cluster: id } }); return; } - await selectCluster(id); - await navigate({ to: destination }); - }; return (
- setRegisterOpen(true)}>{intl.formatMessage({ id: "web.routes.clusters.register_cluster", defaultMessage: "Register cluster", description: "Clusters: button at the top right that opens the dialog for registering a new cluster; shown only to users who can manage clusters; keep it short" })}} /> {registerOpen && } - { - if (!open && !remove.isPending) { setRemoveTarget(null); remove.reset(); } - }} title={intl.formatMessage({ id: "web.routes.clusters.remove_title", defaultMessage: "Remove {name}?", description: "Clusters: title of the remove-cluster confirmation; {name} is the cluster name" }, { name: removeTarget ?? "" })} - description={
-

{intl.formatMessage({ id: "web.routes.clusters.removes_this_panel_s_connection_to_the", defaultMessage: "Removes this panel's connection to the cluster. Running workloads remain on the cluster.", description: "Clusters: text in the remove-cluster confirmation dialog saying the panel's connection is removed while running workloads stay on the cluster" })}

- {remove.error &&

{intl.formatMessage({ id: "web.routes.clusters.couldn_t_remove_the_cluster", defaultMessage: "Couldn't remove the cluster registration. Try again.", description: "Clusters: error message in the remove-cluster dialog shown when removing the registration fails; the user can try again" })}

} -
} - confirmLabel={intl.formatMessage({ id: "web.routes.clusters.remove_cluster", defaultMessage: "Remove cluster", description: "Clusters: confirm button in the remove-cluster dialog, shown in a destructive style; keep it short" })} destructive busy={remove.isPending} onConfirm={() => removeTarget && remove.mutate(removeTarget)} /> - {!isLoading && !error && !can(me, "cluster:manage") && !clusters.some((item) => item.canViewInventory) ? ( - - ) : isLoading ? ( + {isLoading ? ( ) : error ? ( void refetch()} /> @@ -96,7 +65,6 @@ export function ClustersPage() {

{cluster.displayName || cluster.name}

{cluster.name}

- {cluster.name === currentCluster && {intl.formatMessage({ id: "web.routes.clusters.selected", defaultMessage: "Selected", description: "Clusters: small badge on a cluster card marking the cluster currently selected; keep it short" })}}

{intl.formatMessage({ id: "web.routes.clusters.api", defaultMessage: "API:", description: "Clusters: label before the API health value on a cluster card, such as Healthy or Unhealthy; the value is colored by its state; keep it short" })} {cluster.phase ? clusterPhaseLabel(intl, cluster.phase) : intl.formatMessage({ id: "web.routes.clusters.phase_unknown", defaultMessage: "Unknown", description: "Clusters: API status shown when the cluster reports no phase" })} @@ -107,9 +75,9 @@ export function ClustersPage() { {cluster.message &&

{cluster.message}

}
- - {cluster.canViewInventory === true && } - {cluster.name !== "local" && can(me, "cluster:manage") && } + + + {cluster.canViewInventory === true && }
diff --git a/web/src/routes/CreateServer.test.tsx b/web/src/routes/CreateServer.test.tsx index 00e1e13db..c215ffe6e 100644 --- a/web/src/routes/CreateServer.test.tsx +++ b/web/src/routes/CreateServer.test.tsx @@ -747,7 +747,7 @@ describe("explicit creation location", () => { search = { cluster: "local", template: "minecraft-java" }; fetchMock.mockResolvedValue(new Response(JSON.stringify({ items: [{ cluster: "remote", namespace: "games", templates: [template()] }], partial: false, issues: [], totalReturned: 1 }), { status: 200, headers: { "Content-Type": "application/json" } })); renderWithQuery(); - expect(await screen.findByText("No available location")).toBeInTheDocument(); + expect(await screen.findByText("No available cluster")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Continue to Configure/i })).not.toBeInTheDocument(); expect(fetchMock.mock.calls.every(([, options]) => !options?.method || options.method === "GET")).toBe(true); }); diff --git a/web/src/routes/CreateServer.tsx b/web/src/routes/CreateServer.tsx index aaeebea70..8de9c64c0 100644 --- a/web/src/routes/CreateServer.tsx +++ b/web/src/routes/CreateServer.tsx @@ -360,9 +360,9 @@ export function CreateServerWizard() { const requested = choices.filter((p) => (!search.cluster || p.cluster === search.cluster) && (!search.ns || p.namespace === search.ns)); const placement = selected ? choices.find((p) => key(p) === selected) : requested.find((p) => p.cluster === "local") ?? requested[0]; if (!selected && placement) setSelected(key(placement)); - if (placements.isPending) return ; - if (placements.error) return void placements.refetch()} />; - if (!placement) return ; + if (placements.isPending) return ; + if (placements.error) return void placements.refetch()} />; + if (!placement) return ; return ; } @@ -480,12 +480,12 @@ function PlacedServerWizard({ placement, choices, onPlacementChange, partial }:
- onPlacementChange(String(value))}> + + {choices.map((p) => {intl.formatMessage({ id: "web.routes.create_server.cluster_namespace", defaultMessage: "{cluster} / {namespace}", description: "Create server: cluster choice label; {cluster} is the cluster name and {namespace} the namespace" }, { cluster: p.cluster, namespace: p.namespace })})} -

{intl.formatMessage({ id: "web.routes.create_server.changing_location_starts_a_fresh_draft", defaultMessage: "Changing location starts a fresh draft. The chosen location handles all creation steps.", description: "Create server: hint under the location dropdown warning that switching location starts a fresh draft and the chosen location handles all steps" })}

- {partial &&

{intl.formatMessage({ id: "web.routes.create_server.some_locations_are_unavailable", defaultMessage: "Some locations are unavailable. Available locations are shown.", description: "Create server: status note under the location dropdown shown when some locations are unavailable and only the available ones are listed" })}

} +

{intl.formatMessage({ id: "web.routes.create_server.changing_location_starts_a_fresh_draft", defaultMessage: "Changing cluster starts a fresh draft. The chosen cluster handles all creation steps.", description: "Create server: hint under the cluster dropdown warning that switching cluster starts a fresh draft and the chosen cluster handles all steps" })}

+ {partial &&

{intl.formatMessage({ id: "web.routes.create_server.some_locations_are_unavailable", defaultMessage: "Some clusters are unavailable. Available clusters are shown.", description: "Create server: status note under the cluster dropdown shown when some clusters are unavailable and only the available ones are listed" })}

}
@@ -1237,9 +1237,9 @@ function TunnelPortMappingsEditor({ const REVIEW_LABELS = defineMessages({ template: { id: "web.routes.create_server.review_template", defaultMessage: "Template", description: "Create server: review section title and row label for the chosen game template" }, version: { id: "web.routes.create_server.review_version", defaultMessage: "Version", description: "Create server: review section title and row label for the chosen game version" }, - configuration: { id: "web.routes.create_server.review_configuration", defaultMessage: "Configuration", description: "Create server: review section title for the server name, location and resources" }, + configuration: { id: "web.routes.create_server.review_configuration", defaultMessage: "Configuration", description: "Create server: review section title for the server name, cluster and resources" }, name: { id: "web.routes.create_server.review_name", defaultMessage: "Name", description: "Create server: review row label for the server name" }, - location: { id: "web.routes.create_server.review_location", defaultMessage: "Location", description: "Create server: review row label for the cluster and namespace the server is created in" }, + location: { id: "web.routes.create_server.review_location", defaultMessage: "Cluster / namespace", description: "Create server: review row label for the cluster and namespace the server is created in" }, cpu: { id: "web.routes.create_server.review_cpu", defaultMessage: "CPU", description: "Create server: review row label for the CPU limit" }, memory: { id: "web.routes.create_server.review_memory", defaultMessage: "Memory", description: "Create server: review row label for the memory limit" }, cpu_reserved: { id: "web.routes.create_server.review_cpu_reserved", defaultMessage: "Reserved CPU", description: "Create server: review row label for the CPU reserved for the server (the Kubernetes request), shown when the reserve-less switch is on" }, diff --git a/web/src/routes/Dashboard.test.tsx b/web/src/routes/Dashboard.test.tsx index 39e5372c3..e36440ff4 100644 --- a/web/src/routes/Dashboard.test.tsx +++ b/web/src/routes/Dashboard.test.tsx @@ -286,16 +286,16 @@ describe("DashboardPage", () => { await screen.findByText("Recent backups"); expect(screen.queryByText("Recent activity")).not.toBeInTheDocument(); // The registry explicitly denies node inventory for this server-only viewer. - expect(screen.queryByRole("link", { name: /view cluster/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /view nodes/i })).not.toBeInTheDocument(); }); - it("shows the View cluster link with the registry's inventory capability", async () => { + it("shows the View nodes link with the registry's inventory capability", async () => { server.use(http.get("/users/me", () => HttpResponse.json(makeUser({ role: "operator" })))); const { client } = renderWithQuery(); await waitFor(() => expect(client.getQueryData(["me"])).toBeTruthy()); await screen.findByText("Cluster resources"); - const viewClusterLink = screen.getByRole("link", { name: /view cluster/i }); - expect(viewClusterLink).toHaveAttribute("href", "/clusters"); + const viewNodesLink = screen.getByRole("link", { name: /view nodes/i }); + expect(viewNodesLink).toHaveAttribute("href", "/nodes"); }); it("renders vcpus as — when no node data is available", async () => { diff --git a/web/src/routes/Dashboard.tsx b/web/src/routes/Dashboard.tsx index b207e553b..fdbca2589 100644 --- a/web/src/routes/Dashboard.tsx +++ b/web/src/routes/Dashboard.tsx @@ -113,7 +113,7 @@ export function DashboardPage() {
@@ -265,7 +266,7 @@ function FleetStatusCard({
{complete ? intl.formatMessage({ id: "web.routes.dashboard.everything_healthy", defaultMessage: "Everything looks healthy.", description: "Dashboard: Needs attention message when the fleet is complete and nothing needs attention" }) - : intl.formatMessage({ id: "web.routes.dashboard.no_issues_partial", defaultMessage: "No issues among returned servers; some locations are unavailable.", description: "Dashboard: Needs attention message when the fleet is partial and no returned server needs attention" })} + : intl.formatMessage({ id: "web.routes.dashboard.no_issues_partial", defaultMessage: "No issues among returned servers; some clusters are unavailable.", description: "Dashboard: Needs attention message when the fleet is partial and no returned server needs attention" })}
) : ( attention.map((gs) => ( @@ -336,6 +337,7 @@ function ClusterResourcesCard({ nodesReady, nodesTotal, canViewCluster, + clusterId, }: { cpu: Usage; mem: Usage; @@ -346,6 +348,7 @@ function ClusterResourcesCard({ nodesReady: number; nodesTotal: number; canViewCluster: boolean; + clusterId?: string; }) { const intl = useOptionalIntl(); return ( @@ -353,7 +356,7 @@ function ClusterResourcesCard({

{intl.formatMessage({ id: "web.routes.dashboard.cluster_resources", defaultMessage: "Cluster resources", description: "Dashboard: heading of the card showing CPU, memory and storage use across the clusters the user can see" })}

{canViewCluster ? ( - {intl.formatMessage({ id: "web.routes.dashboard.view_clusters", defaultMessage: "View clusters", description: "Dashboard: link at the top right of the cluster resources card that opens the clusters page; keep it short" })} + {intl.formatMessage({ id: "web.routes.dashboard.view_nodes", defaultMessage: "View nodes", description: "Dashboard: link from cluster resources to node inventory, retaining the current cluster filter" })} ) : ( {nodesTotal > 0 ? intl.formatMessage({ id: "web.routes.dashboard.nodes_ready_of_total", defaultMessage: "{ready}/{total} nodes ready", description: "Dashboard: node readiness in the Cluster resources card; {ready} and {total} are counts" }, { ready: nodesReady, total: nodesTotal }) : intl.formatMessage({ id: "web.common.empty_value", defaultMessage: "—", description: "Shown in place of a value that is not set" })} diff --git a/web/src/routes/Fleet.test.tsx b/web/src/routes/Fleet.test.tsx index 207a8e8cd..b288e35c3 100644 --- a/web/src/routes/Fleet.test.tsx +++ b/web/src/routes/Fleet.test.tsx @@ -73,9 +73,9 @@ describe("unified fleet pages", () => { })); renderWithQuery(); await screen.findByText(/Server results are partial/); - expect(screen.queryByRole("button", { name: /Filter by location/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Filter by cluster/ })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /^Filter$/ })); - await userEvent.click(screen.getByRole("button", { name: /Filter by location/ })); + await userEvent.click(screen.getByRole("button", { name: /Filter by cluster/ })); await userEvent.click(await screen.findByRole("option", { name: "remote" })); expect(requested).not.toContain("remote"); await userEvent.click(screen.getByRole("button", { name: "Apply" })); diff --git a/web/src/routes/ServerDetail.tsx b/web/src/routes/ServerDetail.tsx index 5ba7076ad..4ed7961fd 100644 --- a/web/src/routes/ServerDetail.tsx +++ b/web/src/routes/ServerDetail.tsx @@ -104,7 +104,7 @@ function ResolveServerDetail({ target }: { target: ResourceTarget }) { } }, [target.cluster, target.namespace, target.name, server.data?.metadata.namespace, navigate]); const retry = () => { void server.refetch(); void access.refetch(); }; - if (server.error || access.error) return
; + if (server.error || access.error) return
; if (!server.data || !access.data) return
; const authorized = access.data.target; if (authorized.cluster !== resolved.cluster || authorized.namespace !== resolved.namespace || authorized.name !== resolved.name || (resolved.uid && authorized.uid !== resolved.uid)) { diff --git a/web/src/routes/Servers.tsx b/web/src/routes/Servers.tsx index 5ed53e060..ac6a05304 100644 --- a/web/src/routes/Servers.tsx +++ b/web/src/routes/Servers.tsx @@ -114,7 +114,7 @@ export function ServersPage() { const appliedFacetCount = appliedGames.size + appliedNamespaces.size + (location ? 1 : 0); const locationChoices = [...(scopes?.scopes ?? []).map((scope) => scope.cluster), ...(scopes?.items ?? []).map((item) => item.target.cluster), ...(scopes?.issues ?? []).map((issue) => issue.cluster)]; const locationField =
-
{intl.formatMessage({ id: "web.routes.servers.location", defaultMessage: "Location", description: "Servers: label of the location filter in the filter panel; the choices are the clusters the user can access" })}
+
{intl.formatMessage({ id: "web.routes.servers.location", defaultMessage: "Cluster", description: "Servers: label of the cluster filter in the filter panel; the choices are the clusters the user can access" })}
; @@ -181,7 +181,7 @@ export function ServersPage() { {!isMobile && ( {intl.formatMessage({ id: "web.routes.servers.create_server", defaultMessage: "Create server", description: "Servers: button at the top right that opens the form to create a new game server; shown only to users who can create servers; keep it short" })} : undefined diff --git a/web/src/routes/Standalone.test.tsx b/web/src/routes/Standalone.test.tsx index 159f4d8cd..3ef5abc10 100644 --- a/web/src/routes/Standalone.test.tsx +++ b/web/src/routes/Standalone.test.tsx @@ -1,12 +1,11 @@ import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { http, HttpResponse } from "msw"; -import { act, screen, waitFor, within } from "@testing-library/react"; +import { act, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { server } from "@/test/server"; import { makeClient, renderWithQuery } from "@/test/render"; -import { getCurrentCluster, setCurrentCluster } from "@/lib/cluster"; -import { ClusterPage } from "./Cluster"; +import { setCurrentCluster } from "@/lib/cluster"; import { ClustersPage } from "./Clusters"; import { ModulesPage } from "./Modules"; import { AdminLogsPage } from "./AdminLogs"; @@ -108,28 +107,6 @@ describe("standalone panel", () => { expect(await screen.findByRole("heading", { name: "Modules" })).toBeInTheDocument(); }); - it("scopes removal errors to the dialog, resets them, and clears the removed selection", async () => { - standalone([{ name: "east" }, { name: "west" }]); - setCurrentCluster("east"); - let fails = true; - server.use(http.delete("/clusters/east", () => fails - ? HttpResponse.text("failed", { status: 503 }) : new HttpResponse(null, { status: 204 }))); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: "Remove cluster east" })); - await userEvent.click(screen.getByRole("button", { name: "Remove cluster" })); - const dialog = screen.getByRole("alertdialog"); - expect(await within(dialog).findByRole("alert")).toHaveTextContent("Couldn't remove the cluster registration."); - expect(getCurrentCluster()).toBe("east"); - await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); - await userEvent.click(screen.getByRole("button", { name: "Remove cluster west" })); - expect(within(screen.getByRole("alertdialog")).queryByRole("alert")).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Cancel" })); - fails = false; - await userEvent.click(screen.getByRole("button", { name: "Remove cluster east" })); - await userEvent.click(screen.getByRole("button", { name: "Remove cluster" })); - await waitFor(() => expect(getCurrentCluster()).toBe("")); - expect(localStorage.getItem("gameplane.cluster")).toBe(""); - }); it("shows the panel build in About without fetching local cluster metadata", async () => { standalone(); @@ -162,47 +139,7 @@ describe("standalone panel", () => { expect(paths.every((path) => new URL(path).searchParams.get("cluster") === "east")).toBe(true); }); - it("does not request backup destinations until a registered workload is selected", async () => { - standalone(); - const destinations = vi.fn(() => HttpResponse.json({ items: [] })); - server.use(http.get("/backup-destinations", destinations)); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: "Backup destinations" })); - expect(await screen.findByText("Select a workload cluster")).toBeInTheDocument(); - expect(destinations).not.toHaveBeenCalled(); - expect(screen.queryByRole("button", { name: "Add destination" })).not.toBeInTheDocument(); - }); - it("lists, creates and deletes backup destinations on the selected remote", async () => { - standalone([{ name: "east" }]); - setCurrentCluster("east"); - const requests: Array<{ method: string; cluster: string | null }> = []; - let exists = false; - server.use(http.all("/backup-destinations", ({ request }) => { - requests.push({ method: request.method, cluster: new URL(request.url).searchParams.get("cluster") }); - if (request.method === "POST") exists = true; - return HttpResponse.json({ items: exists ? [{ name: "repo", url: "s3:example/repo", hasPassword: true }] : [] }); - }), http.delete("/backup-destinations/repo", ({ request }) => { - requests.push({ method: request.method, cluster: new URL(request.url).searchParams.get("cluster") }); - exists = false; - return new HttpResponse(null, { status: 204 }); - })); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: "Backup destinations" })); - await userEvent.click(await screen.findByRole("button", { name: "Add destination" })); - await userEvent.type(screen.getByPlaceholderText("gameplane-backup-repo"), "repo"); - await userEvent.type(screen.getByPlaceholderText("s3:s3.example.com/gameplane-bucket"), "s3:example/repo"); - await userEvent.type(screen.getByPlaceholderText("Strong, unique passphrase"), "password"); - await userEvent.click(screen.getByRole("button", { name: "Save destination" })); - await userEvent.click(await screen.findByRole("button", { name: "Delete repo" })); - await userEvent.type(screen.getByPlaceholderText("Type to confirm"), "repo"); - await userEvent.click(screen.getByRole("button", { name: "Delete" })); - await waitFor(() => expect(screen.queryByRole("button", { name: "Delete repo" })).not.toBeInTheDocument()); - expect(requests).toEqual(expect.arrayContaining([ - { method: "GET", cluster: "east" }, { method: "POST", cluster: "east" }, { method: "DELETE", cluster: "east" }, - ])); - expect(requests.every((request) => request.cluster === "east")).toBe(true); - }); it("lets remote module managers edit sources without panel configuration access", async () => { standalone([{ name: "east" }]); @@ -223,27 +160,6 @@ describe("standalone panel", () => { await waitFor(() => expect(remove).toHaveBeenCalledTimes(1)); }); - it("discards a backup destination draft when switching workload clusters", async () => { - standalone([{ name: "east" }, { name: "west" }]); - setCurrentCluster("east"); - const creates = vi.fn(() => HttpResponse.json({})); - const reads: string[] = []; - server.use(http.get("/backup-destinations", ({ request }) => { - reads.push(new URL(request.url).searchParams.get("cluster") ?? "local"); - return HttpResponse.json({ items: [] }); - }), http.post("/backup-destinations", creates)); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: "Backup destinations" })); - await userEvent.click(await screen.findByRole("button", { name: "Add destination" })); - await userEvent.type(screen.getByPlaceholderText("gameplane-backup-repo"), "east-draft"); - await userEvent.click(screen.getByRole("button", { name: "Select cluster" })); - await userEvent.click(await screen.findByRole("menuitem", { name: "west" })); - await waitFor(() => expect(reads).toContain("west")); - expect(screen.queryByDisplayValue("east-draft")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Save destination" })).not.toBeInTheDocument(); - expect(creates).not.toHaveBeenCalled(); - expect(reads).not.toContain("local"); - }); it("shows container runtime log guidance without opening a local log stream", async () => { standalone(); @@ -292,16 +208,12 @@ describe("standalone panel", () => { expect(await screen.findByText("Couldn't load installation capabilities.")).toBeInTheDocument(); expect(catalog).not.toHaveBeenCalled(); }); - it("does not request local node inventory or module resources on an empty installation", async () => { + it("does not request local module resources on an empty installation", async () => { standalone(); setCurrentCluster("local"); const workload = vi.fn(() => HttpResponse.json({ items: [] })); server.use(http.get("/cluster", workload), http.get("/cluster/info", workload), http.get("/modules/catalog", workload), http.get("/modules/sources", workload), http.get("/templates", workload)); - const inventory = renderWithQuery(); - expect(await screen.findByText("Select a workload cluster")).toBeInTheDocument(); - expect(workload).not.toHaveBeenCalled(); - inventory.unmount(); renderWithQuery(); expect(await screen.findByText("Select a workload cluster")).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Manage clusters" })).toHaveAttribute("href", "/clusters"); @@ -360,15 +272,5 @@ describe("standalone panel", () => { expect(screen.queryByLabelText("Kubeconfig")).not.toBeInTheDocument(); }); - it("requires confirmation before removing a remote registration", async () => { - standalone([{ name: "east", displayName: "East" }]); - const remove = vi.fn(() => new HttpResponse(null, { status: 204 })); - server.use(http.delete("/clusters/east", remove)); - renderWithQuery(); - await userEvent.click(await screen.findByRole("button", { name: "Remove cluster East" })); - expect(remove).not.toHaveBeenCalled(); - expect(await screen.findByText(/Running workloads remain/)).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Remove cluster" })); - await waitFor(() => expect(remove).toHaveBeenCalledTimes(1)); - }); + });