From 997d403de43f2ac573b4450a41bb648750b4d8c1 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Tue, 15 Sep 2026 00:57:14 +0200 Subject: [PATCH] feat(nodes): add fleet operations dashboard (#12046) * feat(nodes): report CPU telemetry Assisted-by: Codex:gpt-6 * feat(nodes): add fleet view utilities Assisted-by: Codex:gpt-6 * feat(nodes): add fleet operations dashboard Replace the panel roster with aggregate capacity gauges, fleet filtering and selection, bounded bulk actions, and an on-demand node inspector. Extend node details and distributed-mode documentation with CPU and models-disk telemetry. Assisted-by: Codex:gpt-6 * fix(nodes): harden fleet lifecycle actions Assisted-by: Codex:gpt-6 * fix(nodes): restore compact fleet composition Keep fleet health, capacity, and attention in one compact overview at ordinary desktop widths. The inspector now overlays the roster until the workbench can preserve a useful table beside it. Assisted-by: Codex:gpt-6 * feat(nodes): add accessible running models workbench Assisted-by: Codex:gpt-6 * fix(nodes): correct model view ARIA links Keep each tab panel available for its controlling tab while native hidden state removes inactive content from accessibility navigation. Model controls now expose only supported state and valid inspector relationships. Assisted-by: Codex:gpt-6 * fix(nodes): align lifecycle and capacity states Pending nodes now expose approval wherever node actions appear, while other lifecycle controls follow the server transition rules. Capacity totals exclude incomplete readings so missing availability remains unknown. Assisted-by: Codex:gpt-6 * fix(nodes): restore approved dashboard composition Assisted-by: Codex:gpt-6 * fix(nodes): integrate operate navigation Assisted-by: Codex:gpt-6 * fix(nodes): restore low density fleet view Assisted-by: Codex:gpt-6 * fix(nodes): preserve complete operate menu Assisted-by: Codex:gpt-6 * fix(nodes): preserve inspector workspace height Assisted-by: Codex:gpt-6 * fix(nodes): restore standard operate navigation Assisted-by: Codex:gpt-6 * feat(ui): add collapsible console rail Assisted-by: Codex:gpt-6 * feat(nodes): stop models from fleet view Assisted-by: Codex:gpt-6 * fix(nodes): make inspector a full height drawer Assisted-by: Codex:gpt-6 * fix(model): stop mixed local and remote placements Assisted-by: Codex:gpt-6 * fix(ui): announce action menu navigation Assisted-by: Codex:gpt-6 * fix(nodes): keep inspector within viewport Assisted-by: Codex:gpt-6 * fix(ui): preserve focus across model actions Assisted-by: Codex:gpt-6 --------- Co-authored-by: Ettore Di Giacinto --- core/http/endpoints/localai/nodes.go | 41 +- core/http/endpoints/localai/nodes_test.go | 65 ++ core/http/react-ui/e2e/console-narrow.spec.js | 12 + .../e2e/installed-model-logs-link.spec.js | 20 + core/http/react-ui/e2e/navigation.spec.js | 22 + core/http/react-ui/e2e/nodes-detail.spec.js | 58 +- .../e2e/nodes-fleet-dashboard.spec.js | 747 ++++++++++++++++++ core/http/react-ui/e2e/nodes-roster.spec.js | 73 +- core/http/react-ui/inline-style-baseline.txt | 2 +- core/http/react-ui/src/App.css | 272 ++++++- .../react-ui/src/components/ActionMenu.jsx | 17 +- .../react-ui/src/components/ConfirmDialog.jsx | 13 +- core/http/react-ui/src/components/Popover.jsx | 24 +- .../src/components/console/ConsoleLayout.jsx | 30 +- .../src/components/nodes/ClusterOverview.jsx | 93 +++ .../src/components/nodes/ModelFleetTable.jsx | 63 ++ .../src/components/nodes/ModelInspector.jsx | 84 ++ .../src/components/nodes/NodeFleetTable.jsx | 92 +++ .../src/components/nodes/NodeInspector.jsx | 107 +++ .../src/components/nodes/nodeStatus.js | 19 + .../components/nodes/useInspectorDrawer.js | 90 +++ core/http/react-ui/src/pages/NodeDetail.jsx | 48 +- core/http/react-ui/src/pages/Nodes.jsx | 624 ++++++++------- core/http/react-ui/src/utils/nodeFleet.js | 362 +++++++++ .../http/react-ui/src/utils/nodeFleet.test.js | 355 +++++++++ .../nodes/heartbeat_checkpoint_test.go | 25 + core/services/nodes/registry.go | 110 ++- core/services/nodes/registry_test.go | 85 ++ core/services/nodes/unloader.go | 5 + core/services/nodes/unloader_test.go | 17 + core/services/worker/registration.go | 27 + core/services/worker/registration_test.go | 41 + docs/content/features/distributed-mode.md | 44 +- docs/content/operations/backend-monitor.md | 2 +- pkg/model/loader.go | 4 +- pkg/model/process.go | 25 +- pkg/model/remote_shutdown_test.go | 45 ++ pkg/xsysinfo/cpu.go | 40 + pkg/xsysinfo/cpu_test.go | 20 + 39 files changed, 3384 insertions(+), 439 deletions(-) create mode 100644 core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js create mode 100644 core/http/react-ui/src/components/nodes/ClusterOverview.jsx create mode 100644 core/http/react-ui/src/components/nodes/ModelFleetTable.jsx create mode 100644 core/http/react-ui/src/components/nodes/ModelInspector.jsx create mode 100644 core/http/react-ui/src/components/nodes/NodeFleetTable.jsx create mode 100644 core/http/react-ui/src/components/nodes/NodeInspector.jsx create mode 100644 core/http/react-ui/src/components/nodes/useInspectorDrawer.js create mode 100644 core/http/react-ui/src/utils/nodeFleet.js create mode 100644 core/http/react-ui/src/utils/nodeFleet.test.js create mode 100644 pkg/xsysinfo/cpu_test.go diff --git a/core/http/endpoints/localai/nodes.go b/core/http/endpoints/localai/nodes.go index 220682b92..331c7203b 100644 --- a/core/http/endpoints/localai/nodes.go +++ b/core/http/endpoints/localai/nodes.go @@ -75,15 +75,18 @@ func GetNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc { // RegisterNodeRequest is the request body for registering a new worker node. type RegisterNodeRequest struct { - Name string `json:"name"` - NodeType string `json:"node_type,omitempty"` // "backend" (default) or "agent" - Address string `json:"address"` - HTTPAddress string `json:"http_address,omitempty"` - Token string `json:"token,omitempty"` - TotalVRAM uint64 `json:"total_vram,omitempty"` - AvailableVRAM uint64 `json:"available_vram,omitempty"` - TotalRAM uint64 `json:"total_ram,omitempty"` - AvailableRAM uint64 `json:"available_ram,omitempty"` + Name string `json:"name"` + NodeType string `json:"node_type,omitempty"` // "backend" (default) or "agent" + Address string `json:"address"` + HTTPAddress string `json:"http_address,omitempty"` + Token string `json:"token,omitempty"` + TotalVRAM uint64 `json:"total_vram,omitempty"` + AvailableVRAM uint64 `json:"available_vram,omitempty"` + TotalRAM uint64 `json:"total_ram,omitempty"` + AvailableRAM uint64 `json:"available_ram,omitempty"` + CPULogicalCores uint64 `json:"cpu_logical_cores,omitempty"` + CPUUsagePercent float64 `json:"cpu_usage_percent,omitempty"` + CPULoad1 float64 `json:"cpu_load_1,omitempty"` // TotalDisk / AvailableDisk describe the filesystem backing the worker's // MODELS directory (where staged weights land), not the root filesystem. // Omitted by workers that predate the fields; the scheduler treats @@ -182,6 +185,9 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au AvailableVRAM: req.AvailableVRAM, TotalRAM: req.TotalRAM, AvailableRAM: req.AvailableRAM, + CPULogicalCores: req.CPULogicalCores, + CPUUsagePercent: req.CPUUsagePercent, + CPULoad1: req.CPULoad1, TotalDisk: req.TotalDisk, AvailableDisk: req.AvailableDisk, GPUVendor: req.GPUVendor, @@ -381,7 +387,8 @@ func HeartbeatEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc { var updatePtr *nodes.HeartbeatUpdate if update.AvailableVRAM != nil || update.TotalVRAM != nil || update.AvailableRAM != nil || - update.AvailableDisk != nil || update.TotalDisk != nil || update.GPUVendor != "" { + update.AvailableDisk != nil || update.TotalDisk != nil || update.GPUVendor != "" || + update.CPUUsagePercent != nil || update.CPULoad1 != nil { updatePtr = &update } @@ -431,6 +438,12 @@ func DrainNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc { ctx := c.Request().Context() id := c.Param("id") if err := registry.MarkDraining(ctx, id); err != nil { + if errors.Is(err, nodes.ErrNodeNotFound) { + return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found")) + } + if errors.Is(err, nodes.ErrNodeStatusConflict) { + return c.JSON(http.StatusConflict, nodeError(http.StatusConflict, "node must be healthy to drain")) + } xlog.Error("Failed to drain node", "id", id, "error", err) return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to drain node")) } @@ -443,7 +456,13 @@ func ResumeNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc { return func(c echo.Context) error { ctx := c.Request().Context() id := c.Param("id") - if err := registry.MarkHealthy(ctx, id); err != nil { + if err := registry.ResumeNode(ctx, id); err != nil { + if errors.Is(err, nodes.ErrNodeNotFound) { + return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found")) + } + if errors.Is(err, nodes.ErrNodeStatusConflict) { + return c.JSON(http.StatusConflict, nodeError(http.StatusConflict, "node must be draining to resume")) + } xlog.Error("Failed to resume node", "id", id, "error", err) return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to resume node")) } diff --git a/core/http/endpoints/localai/nodes_test.go b/core/http/endpoints/localai/nodes_test.go index 19e6a6b07..8390f1a48 100644 --- a/core/http/endpoints/localai/nodes_test.go +++ b/core/http/endpoints/localai/nodes_test.go @@ -58,6 +58,35 @@ var _ = Describe("Node HTTP handlers", func() { }) Describe("RegisterNodeEndpoint", func() { + It("binds, persists, and lists CPU telemetry", func() { + e := echo.New() + body := `{"name":"cpu-worker","address":"10.0.0.9:50051","cpu_logical_cores":12,"cpu_usage_percent":145,"cpu_load_1":2.5}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + Expect(RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})(e.NewContext(req, rec))).To(Succeed()) + Expect(rec.Code).To(Equal(http.StatusCreated)) + + node, err := registry.GetByName(context.Background(), "cpu-worker") + Expect(err).ToNot(HaveOccurred()) + Expect(node.CPULogicalCores).To(Equal(uint64(12))) + Expect(node.CPUUsagePercent).To(Equal(float64(100))) + Expect(node.CPULoad1).To(Equal(2.5)) + + listRecorder := httptest.NewRecorder() + listContext := e.NewContext(httptest.NewRequest(http.MethodGet, "/api/nodes", nil), listRecorder) + Expect(ListNodesEndpoint(registry)(listContext)).To(Succeed()) + var listed []map[string]any + Expect(json.Unmarshal(listRecorder.Body.Bytes(), &listed)).To(Succeed()) + Expect(listed).To(HaveLen(1)) + Expect(listed[0]).To(SatisfyAll( + HaveKeyWithValue("cpu_logical_cores", float64(12)), + HaveKeyWithValue("cpu_usage_percent", float64(100)), + HaveKeyWithValue("cpu_load_1", 2.5), + )) + }) + It("registers a backend node and returns 201", func() { e := echo.New() body := `{"name":"worker-1","address":"10.0.0.1:50051"}` @@ -449,6 +478,42 @@ var _ = Describe("Node HTTP handlers", func() { }) }) + Describe("Node lifecycle endpoints", func() { + request := func(handler echo.HandlerFunc, id string) *httptest.ResponseRecorder { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/api/nodes/"+id, nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues(id) + Expect(handler(c)).To(Succeed()) + return rec + } + + It("accepts healthy drain followed by draining resume", func() { + Expect(registry.Register(context.Background(), &nodes.BackendNode{ + ID: "lifecycle", Name: "lifecycle", Address: "10.0.0.10:50051", + }, true)).To(Succeed()) + + Expect(request(DrainNodeEndpoint(registry), "lifecycle").Code).To(Equal(http.StatusOK)) + Expect(request(ResumeNodeEndpoint(registry), "lifecycle").Code).To(Equal(http.StatusOK)) + }) + + It("returns conflict when a pending node is drained or resumed", func() { + Expect(registry.Register(context.Background(), &nodes.BackendNode{ + ID: "pending-lifecycle", Name: "pending-lifecycle", Address: "10.0.0.11:50051", + }, false)).To(Succeed()) + + Expect(request(DrainNodeEndpoint(registry), "pending-lifecycle").Code).To(Equal(http.StatusConflict)) + Expect(request(ResumeNodeEndpoint(registry), "pending-lifecycle").Code).To(Equal(http.StatusConflict)) + }) + + It("returns not found for missing nodes", func() { + Expect(request(DrainNodeEndpoint(registry), "missing").Code).To(Equal(http.StatusNotFound)) + Expect(request(ResumeNodeEndpoint(registry), "missing").Code).To(Equal(http.StatusNotFound)) + }) + }) + Describe("GetNodeModelsEndpoint", func() { It("returns revision and cleanup state without serialized model options", func() { ctx := context.Background() diff --git a/core/http/react-ui/e2e/console-narrow.spec.js b/core/http/react-ui/e2e/console-narrow.spec.js index a7f7bfc73..0e9780806 100644 --- a/core/http/react-ui/e2e/console-narrow.spec.js +++ b/core/http/react-ui/e2e/console-narrow.spec.js @@ -1,6 +1,18 @@ import { test, expect } from './coverage-fixtures.js' test.describe('Operate console on a narrow screen', () => { + test('ignores the desktop collapsed preference', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.addInitScript(() => localStorage.setItem('localai_console_rail_collapsed', 'true')) + await page.goto('/app/operate') + + const rail = page.locator('.console-rail') + await expect(rail).toHaveCSS('width', '374px') + await expect(rail.getByText('Operate', { exact: true })).toBeVisible() + await expect(rail.getByRole('button', { name: 'Expand Operate navigation' })).toBeVisible() + await expect(rail.locator('.console-rail-collapse')).toBeHidden() + }) + test('expanding the rail leaves the overview on screen', async ({ page }) => { await page.setViewportSize({ width: 390, height: 800 }) await page.goto('/app/operate') diff --git a/core/http/react-ui/e2e/installed-model-logs-link.spec.js b/core/http/react-ui/e2e/installed-model-logs-link.spec.js index 62fcadf27..e43e16081 100644 --- a/core/http/react-ui/e2e/installed-model-logs-link.spec.js +++ b/core/http/react-ui/e2e/installed-model-logs-link.spec.js @@ -21,4 +21,24 @@ test.describe('Installed model backend logs link', () => { await expect(page).toHaveURL(/\/app\/backend-logs\//) }) + + test('arrow navigation announces the active action through the focused menu', async ({ page }) => { + await page.goto('/app/models?view=installed') + await page.locator('[data-testid="installed-models-rail-item"]').first().click() + const trigger = page.locator('button.action-menu__trigger').first() + await trigger.focus() + await trigger.press('Enter') + + const menu = page.getByRole('menu') + await expect(menu).toBeFocused() + const firstItem = menu.getByRole('menuitem').first() + await expect(menu).toHaveAttribute('aria-activedescendant', await firstItem.getAttribute('id')) + + await menu.press('ArrowDown') + const secondItem = menu.getByRole('menuitem').nth(1) + await expect(menu).toHaveAttribute('aria-activedescendant', await secondItem.getAttribute('id')) + await expect(menu).toBeFocused() + await expect(firstItem).toHaveAttribute('tabindex', '-1') + await expect(secondItem).toHaveAttribute('tabindex', '-1') + }) }) diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index 1a29c07e4..0f35c51aa 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -55,4 +55,26 @@ test.describe('Navigation', () => { await expect(rail.locator('a.nav-item[href="/app/fine-tune"]')).toBeVisible() await expect(rail.locator('a.nav-item[href="/app/face"]')).toBeVisible() }) + + test('desktop console rail collapses to accessible icons and persists globally', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }) + await page.goto('/app/backends') + + const rail = page.locator('.console-rail') + const collapse = rail.getByRole('button', { name: 'Collapse Operate navigation' }) + await expect(collapse).toBeVisible() + await collapse.click() + await expect(rail).toHaveClass(/console-rail--collapsed/) + await expect(rail).toHaveCSS('width', '60px') + await expect(rail.getByRole('link', { name: 'Backends', exact: true })).toHaveClass(/active/) + await expect(rail.getByRole('link', { name: 'Overview', exact: true })).toHaveAttribute('title', 'Overview') + await expect.poll(() => page.evaluate(() => localStorage.getItem('localai_console_rail_collapsed'))).toBe('true') + + await page.goto('/app/agents') + const buildRail = page.locator('.console-rail') + await expect(buildRail).toHaveClass(/console-rail--collapsed/) + await expect(buildRail.getByRole('button', { name: 'Expand Build navigation' })).toBeVisible() + await page.reload() + await expect(page.locator('.console-rail')).toHaveClass(/console-rail--collapsed/) + }) }) diff --git a/core/http/react-ui/e2e/nodes-detail.spec.js b/core/http/react-ui/e2e/nodes-detail.spec.js index 65690ba49..866894a71 100644 --- a/core/http/react-ui/e2e/nodes-detail.spec.js +++ b/core/http/react-ui/e2e/nodes-detail.spec.js @@ -1,9 +1,9 @@ import { test, expect } from './coverage-fixtures.js' const ID = 'n1' -async function mockNode(page) { +async function mockNode(page, overrides = {}) { await page.route(`**/api/nodes/${ID}`, r => r.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', total_vram: 24e9, available_vram: 12e9, max_replicas_per_model: 1, labels: { env: 'prod' } }) })) + body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', total_vram: 24e9, available_vram: 12e9, total_disk: 100e9, available_disk: 40e9, cpu_logical_cores: 16, cpu_usage_percent: 25, cpu_load_1: 2.5, max_replicas_per_model: 1, labels: { env: 'prod' }, ...overrides }) })) await page.route(`**/api/nodes/${ID}/models`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ node_id: ID, model_name: 'llama-3.3', state: 'loaded', in_flight: 0, replica_index: 0 }]) })) await page.route(`**/api/nodes/${ID}/backends`, r => r.fulfill({ status: 200, contentType: 'application/json', @@ -19,16 +19,64 @@ test.describe('Node detail page', () => { await expect(page.getByText('llama-3.3')).toBeVisible() await expect(page.getByText('llama-cpp')).toBeVisible() await expect(page.getByText('env=prod')).toBeVisible() + await expect(page.getByText('25.0% of 16 cores')).toBeVisible() + await expect(page.getByText('2.50 load (1m)')).toBeVisible() + await expect(page.getByText('37.3 GB / 93.1 GB')).toBeVisible() }) test('is reachable by clicking a roster panel', async ({ page }) => { await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' }]) })) - await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) - await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) await mockNode(page) await page.goto('/app/nodes') - await page.locator('.node-panel').filter({ hasText: 'alpha' }).getByText('alpha').click() + await page.getByRole('button', { name: 'Inspect alpha' }).click() + await page.getByRole('link', { name: 'Open full node details' }).click() await expect(page).toHaveURL(new RegExp(`/app/nodes/${ID}$`)) }) + + for (const [status, action] of [['healthy', 'Drain'], ['draining', 'Resume'], ['unhealthy', null], ['offline', null], ['unknown', null]]) { + test(`shows only the accepted lifecycle action for ${status} nodes`, async ({ page }) => { + await mockNode(page, { status }) + await page.goto(`/app/nodes/${ID}`) + await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('button', { name: /Approve/ })).toHaveCount(0) + await expect(page.getByRole('button', { name: /Drain/ })).toHaveCount(action === 'Drain' ? 1 : 0) + await expect(page.getByRole('button', { name: /Resume/ })).toHaveCount(action === 'Resume' ? 1 : 0) + await expect(page.locator('.page-header__meta .btn-danger')).toContainText('Remove') + }) + } + + test('approves a pending node and refreshes its lifecycle controls', async ({ page }) => { + let status = 'pending' + let approvalRequests = 0 + await page.route(`**/api/nodes/${ID}`, r => r.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', status, labels: {} }) })) + await page.route(`**/api/nodes/${ID}/models`, r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.route(`**/api/nodes/${ID}/backends`, r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.route(`**/api/nodes/${ID}/approve`, async r => { + approvalRequests += 1 + status = 'healthy' + await r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + }) + await page.goto(`/app/nodes/${ID}`) + + await page.getByRole('button', { name: 'Approve' }).click() + await expect.poll(() => approvalRequests).toBe(1) + await expect(page.getByText('Node approved')).toBeVisible() + await expect(page.getByRole('button', { name: 'Drain' })).toBeVisible() + await expect(page.locator('.page-header__meta .btn-danger')).toContainText('Remove') + }) + + test('renders valid totals with missing available capacity as No data', async ({ page }) => { + await mockNode(page, { + total_vram: 24e9, available_vram: undefined, + total_ram: 32e9, available_ram: undefined, + total_disk: 100e9, available_disk: undefined, + }) + await page.goto(`/app/nodes/${ID}`) + await expect(page.locator('.node-detail__metrics')).toContainText('VRAM') + await expect(page.locator('.node-detail__metrics')).toContainText('RAM') + await expect(page.locator('.node-detail__metrics')).toContainText('Models disk free') + await expect(page.locator('.node-detail__metrics').getByText('No data')).toHaveCount(3) + }) }) diff --git a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js new file mode 100644 index 000000000..adcb5a822 --- /dev/null +++ b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js @@ -0,0 +1,747 @@ +import { test, expect } from './coverage-fixtures.js' + +const baseNodes = [ + { id: 'n1', name: 'atlas', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', labels: { zone: 'east' }, total_vram: 100, available_vram: 40, total_ram: 200, available_ram: 100, total_disk: 1000, available_disk: 600, cpu_logical_cores: 8, cpu_usage_percent: 25, cpu_load_1: 1.5, model_count: 3, in_flight_count: 2, last_heartbeat: '2026-09-14T00:00:00Z' }, + { id: 'n2', name: 'borealis', node_type: 'backend', address: '10.0.0.2:50051', status: 'pending', labels: { zone: 'west' }, total_vram: 100, available_vram: 10, total_ram: 200, available_ram: 10, total_disk: 1000, available_disk: 100, cpu_logical_cores: 16, cpu_usage_percent: 50, cpu_load_1: 4, model_count: 1, in_flight_count: 0 }, + { id: 'n3', name: 'legacy', node_type: 'agent', address: '10.0.0.3:50051', status: 'offline', labels: {} }, +] + +async function mockNodes(page, nodes = baseNodes) { + await page.route('**/api/nodes', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(nodes) })) +} + +async function mockFullOperateNavigation(page) { + await page.route('**/api/features', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ distributed: true }), + })) + await page.route('**/api/auth/status', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + authEnabled: true, + staticApiKeyRequired: false, + providers: ['local'], + user: { id: 'admin', name: 'Admin', role: 'admin', provider: 'local' }, + }), + })) +} + +const baseModels = [ + { id: 'r1', node_id: 'n1', model_name: 'Llama 3.2', replica_index: 0, address: '10.0.0.1:50101', state: 'loaded', in_flight: 2, backend_type: 'llama-cpp', last_used: '2026-09-14T10:00:00Z' }, + { id: 'r2', node_id: 'n1', model_name: 'Llama 3.2', replica_index: 1, address: '10.0.0.1:50102', state: 'loaded', in_flight: 0, backend_type: 'llama-cpp', last_used: '2026-09-14T10:30:00Z' }, + { id: 'r3', node_id: 'n2', model_name: 'Llama 3.2', replica_index: 0, address: '10.0.0.2:50101', state: 'loaded', in_flight: 1, backend_type: 'vllm', last_used: '2026-09-14T11:00:00Z' }, + { id: 'r4', node_id: 'missing', model_name: 'Whisper large v3', replica_index: 0, address: '10.0.0.9:50101', state: 'loaded', in_flight: 0, backend_type: 'whisper', last_used: '2026-09-14T09:00:00Z' }, +] + +test.describe('Nodes fleet dashboard', () => { + test('uses the standard Operate navigation at a desktop viewport', async ({ page }) => { + await mockFullOperateNavigation(page) + await mockNodes(page, [baseNodes[0]]) + await page.goto('/app/nodes') + + const primaryOperate = page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }) + await expect(primaryOperate).toBeVisible({ timeout: 15_000 }) + await expect(primaryOperate).toHaveClass(/active/) + + const rail = page.locator('.console-layout > .console-rail') + await expect(rail).toBeVisible() + await expect(rail.locator('a.nav-item')).toHaveCount(13) + await expect(rail.locator('a[href="/app/nodes"]')).toHaveClass(/active/) + await expect(rail.locator('a[href$="/swagger/index.html"]')).toHaveAttribute('target', '_blank') + }) + + test('uses the standard collapsible Operate rail on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await mockFullOperateNavigation(page) + await mockNodes(page, [baseNodes[0]]) + await page.goto('/app/nodes') + + await page.getByRole('button', { name: 'Open menu' }).click() + await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toBeVisible() + await page.getByRole('button', { name: 'Close menu' }).click() + + const rail = page.locator('.console-layout > .console-rail') + await expect(rail).toBeVisible() + await expect(rail.locator('.console-rail-groups')).toBeHidden() + await rail.getByRole('button', { name: 'Expand Operate navigation' }).click() + await expect(rail.locator('.console-rail-groups')).toBeVisible() + await expect(rail.locator('a.nav-item')).toHaveCount(13) + }) + + test('shows aggregate health, capacity, attention filtering, search, sorting, and grouping', async ({ page }) => { + await mockNodes(page) + await page.goto('/app/nodes') + await expect(page.getByLabel('Fleet health summary')).toContainText('3 nodes', { timeout: 15_000 }) + await expect(page.getByLabel('VRAM capacity')).toContainText('150 B / 200 B') + await expect(page.getByLabel('CPU capacity')).toContainText('10 busy / 24 cores') + await expect(page.getByLabel('Models disk capacity')).toContainText('1.3 KB / 2 KB') + await expect(page.getByRole('button', { name: /Needs attention.*2/ })).toBeVisible() + await page.getByRole('button', { name: /Low VRAM/ }).click() + await expect(page.getByRole('row', { name: /borealis/ })).toBeVisible() + await expect(page.getByRole('row', { name: /atlas/ })).toHaveCount(0) + await page.getByRole('button', { name: /Low VRAM/ }).click() + await page.getByRole('searchbox', { name: 'Search nodes' }).fill('legacy') + await expect(page.getByRole('row', { name: /legacy/ })).toBeVisible() + await page.getByRole('searchbox', { name: 'Search nodes' }).fill('') + await page.getByRole('button', { name: /Sort by node/ }).click() + await expect(page.locator('tbody tr').first()).toContainText('legacy') + await page.getByLabel('Group nodes').selectOption('label:zone') + await expect(page.getByText('Unlabelled', { exact: true })).toBeVisible() + await expect(page.getByText('east', { exact: true })).toBeVisible() + }) + + test('mounts only 50 rows and clamps pagination for a 1,000-node fleet', async ({ page }) => { + const nodes = Array.from({ length: 1000 }, (_, index) => ({ id: `node-${index}`, name: `worker-${String(index).padStart(4, '0')}`, node_type: 'backend', address: `10.0.${Math.floor(index / 255)}.${index % 255}:50051`, status: 'healthy' })) + await mockNodes(page, nodes) + await page.goto('/app/nodes') + await expect(page.locator('tbody tr')).toHaveCount(50, { timeout: 15_000 }) + await expect(page.getByText('Page 1 of 20')).toBeVisible() + await page.getByRole('button', { name: 'Next page' }).click() + await expect(page.getByText('Page 2 of 20')).toBeVisible() + await page.getByRole('searchbox', { name: 'Search nodes' }).fill('worker-0000') + await expect(page.getByText('Page 1 of 1')).toBeVisible() + }) + + test('filters bulk actions by lifecycle state, reports skipped nodes, and prevents overlapping batches', async ({ page }) => { + const nodes = Array.from({ length: 12 }, (_, index) => ({ + id: `n${index}`, + name: `worker-${index}`, + node_type: 'backend', + status: index === 9 ? 'pending' : index === 10 ? 'draining' : index === 11 ? 'offline' : 'healthy', + })) + await mockNodes(page, nodes) + let active = 0 + let peak = 0 + const drainRequests = [] + const resumeRequests = [] + await page.route('**/api/nodes/*/drain', async route => { + active += 1 + peak = Math.max(peak, active) + await new Promise(resolve => setTimeout(resolve, 30)) + active -= 1 + const id = route.request().url().split('/').at(-2) + drainRequests.push(id) + await route.fulfill({ status: id === 'n8' ? 500 : 200, contentType: 'application/json', body: id === 'n8' ? '{"error":"failed"}' : '{}' }) + }) + await page.route('**/api/nodes/*/resume', async route => { + const id = route.request().url().split('/').at(-2) + resumeRequests.push(id) + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/nodes') + await page.getByRole('checkbox', { name: 'Select visible nodes' }).check() + await page.getByRole('searchbox', { name: 'Search nodes' }).fill('worker-1') + await expect(page.getByText('12 selected')).toBeVisible() + await page.getByRole('button', { name: 'Drain selected' }).evaluate(button => { + button.click() + button.click() + }) + await expect.poll(() => drainRequests.length).toBe(9) + expect(peak).toBeLessThanOrEqual(8) + expect(drainRequests.sort()).toEqual(Array.from({ length: 9 }, (_, index) => `n${index}`).sort()) + await expect(page.getByText(/8 succeeded, 1 failed, 3 skipped/)).toBeVisible() + + await page.getByRole('button', { name: 'Resume selected' }).click() + await expect.poll(() => resumeRequests).toEqual(['n10']) + await expect(page.getByText(/1 succeeded, 0 failed, 11 skipped/)).toBeVisible() + expect(drainRequests).not.toContain('n9') + expect(resumeRequests).not.toContain('n9') + }) + + test('disables bulk controls and remove confirmation while removal is running', async ({ page }) => { + await mockNodes(page, [baseNodes[0]]) + let finishRemove + await page.route('**/api/nodes/n1', async route => { + if (route.request().method() !== 'DELETE') return route.fallback() + await new Promise(resolve => { finishRemove = resolve }) + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/nodes') + await page.getByRole('checkbox', { name: 'Select atlas' }).check() + await page.getByRole('button', { name: 'Remove selected' }).click() + await page.getByRole('button', { name: 'Remove nodes' }).click() + + await expect(page.getByRole('button', { name: 'Removing…' })).toBeDisabled() + await expect(page.getByRole('button', { name: 'Cancel' })).toBeDisabled() + finishRemove() + await expect(page.getByText(/1 succeeded, 0 failed, 0 skipped/)).toBeVisible() + }) + + test('fetches backends only when an inspector opens and shows unknown legacy metrics', async ({ page }) => { + await mockNodes(page) + let backendRequests = 0 + await page.route('**/api/nodes/n3/backends', route => { + backendRequests += 1 + return route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"tool-runner"}]' }) + }) + await page.goto('/app/nodes') + expect(backendRequests).toBe(0) + await page.getByRole('button', { name: 'Inspect legacy' }).click() + const inspector = page.getByRole('complementary', { name: 'Node inspector' }) + await expect(inspector).toContainText('legacy') + await expect(inspector).toContainText('No data') + await expect(inspector).toContainText('1 backend') + expect(backendRequests).toBe(1) + await expect(page.getByRole('link', { name: 'Open full node details' })).toHaveAttribute('href', '/app/nodes/n3') + }) + + test('keeps pending approval visible', async ({ page }) => { + await mockNodes(page, [baseNodes[1]]) + await page.route('**/api/nodes/n2/approve', route => route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })) + await page.goto('/app/nodes') + await expect(page.getByRole('button', { name: 'Approve borealis' })).toBeVisible({ timeout: 15_000 }) + }) + + test('shows inspector lifecycle controls only for server-accepted states', async ({ page }) => { + const statuses = ['healthy', 'draining', 'pending', 'unhealthy', 'offline', 'unknown'] + await mockNodes(page, statuses.map((status, index) => ({ + id: `state-${index}`, + name: `node-${status}`, + node_type: 'backend', + status, + }))) + await page.route('**/api/nodes/*/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.goto('/app/nodes') + + for (const status of statuses) { + await page.getByRole('button', { name: `Inspect node-${status}` }).click() + const inspector = page.getByRole('complementary', { name: 'Node inspector' }) + await expect(inspector.getByRole('button', { name: 'Approve', exact: true })).toHaveCount(status === 'pending' ? 1 : 0) + await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toHaveCount(status === 'healthy' ? 1 : 0) + await expect(inspector.getByRole('button', { name: 'Resume', exact: true })).toHaveCount(status === 'draining' ? 1 : 0) + await inspector.getByRole('button', { name: 'Close node inspector' }).click() + } + }) + + test('approves a pending node from the model-to-node drilldown', async ({ page }) => { + let status = 'pending' + let approvalRequests = 0 + await page.route('**/api/nodes', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ + { id: 'pending-node', name: 'pending-worker', node_type: 'backend', status }, + ]) })) + await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ + { id: 'replica', node_id: 'pending-node', model_name: 'Pending model', replica_index: 0, state: 'loaded' }, + ]) })) + await page.route('**/api/nodes/pending-node/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.route('**/api/nodes/pending-node/approve', async route => { + approvalRequests += 1 + status = 'healthy' + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/nodes') + await page.getByRole('tab', { name: 'Running models' }).click() + await page.getByRole('button', { name: 'Inspect Pending model' }).click() + await page.getByRole('button', { name: 'Open node pending-worker' }).click() + + const inspector = page.getByRole('complementary', { name: 'Node inspector' }) + await inspector.getByRole('button', { name: 'Approve', exact: true }).click() + await expect.poll(() => approvalRequests).toBe(1) + await expect(page.getByText('Node approved')).toBeVisible() + await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeVisible() + }) + + test('keeps incomplete capacity unknown throughout the fleet view', async ({ page }) => { + await mockNodes(page, [{ + id: 'incomplete', name: 'incomplete-capacity', node_type: 'backend', status: 'healthy', + total_vram: 100, total_ram: 200, total_disk: 300, + }]) + await page.route('**/api/nodes/incomplete/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.goto('/app/nodes') + + await expect(page.getByLabel('VRAM capacity')).toContainText('No data') + await expect(page.getByLabel('VRAM capacity')).toContainText('1 node unavailable') + await expect(page.getByRole('button', { name: /Low VRAM.*0/ })).toBeVisible() + const row = page.getByRole('row', { name: /incomplete-capacity/ }) + await expect(row.getByText('No data')).toHaveCount(3) + await page.getByRole('button', { name: 'Inspect incomplete-capacity' }).click() + const inspector = page.getByRole('complementary', { name: 'Node inspector' }) + await expect(inspector.getByText('No data')).toHaveCount(4) + }) + + test('announces complete and partial capacity coverage without adding visible clutter', async ({ page }) => { + const partialNode = { + ...baseNodes[0], + id: 'n-partial', + name: 'partial-capacity', + total_vram: 100, + available_vram: 50, + total_ram: undefined, + available_ram: undefined, + } + await mockNodes(page, [baseNodes[0], partialNode]) + await page.goto('/app/nodes') + + const vram = page.getByLabel('VRAM capacity', { exact: true }) + const ram = page.getByLabel('RAM capacity', { exact: true }) + await expect(vram).toContainText('Capacity coverage: 2 of 2 nodes reporting; 0 unknown.', { timeout: 15_000 }) + await expect(ram).toContainText('Capacity coverage: 1 of 2 nodes reporting; 1 unknown.') + await expect(vram.locator('.fleet-gauge__coverage')).toHaveCount(0) + await expect(ram.locator('.fleet-gauge__coverage')).toHaveText('1 node unavailable') + }) + + test('keeps checkbox keyboard activation from opening the inspector', async ({ page }) => { + await mockNodes(page, [baseNodes[0]]) + await page.goto('/app/nodes') + + const checkbox = page.getByRole('checkbox', { name: 'Select atlas' }) + await checkbox.focus() + await checkbox.press('Space') + + await expect(checkbox).toBeChecked() + await expect(page.getByRole('complementary', { name: 'Node inspector' })).toHaveCount(0) + }) + + test('keeps the low-density composition while inspecting at a desktop viewport', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 1050 }) + await mockNodes(page) + await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"},{"name":"whisper"}]' })) + await page.goto('/app/nodes') + + const overview = page.getByRole('region', { name: 'Fleet overview' }) + const workbench = page.getByRole('region', { name: 'Fleet workbench' }) + await expect(overview).toBeVisible({ timeout: 15_000 }) + await expect(workbench).toBeVisible() + await expect(page.locator('.console-layout > .console-rail')).toBeVisible() + await expect(page.locator('.fleet-select-wrap')).toHaveCount(3) + await expect(page.getByLabel('Filter status')).toHaveCSS('appearance', 'none') + await expect(page.locator('.fleet-bulkbar')).toHaveCount(0) + + const overviewBefore = await overview.boundingBox() + const fleetBefore = await page.locator('#fleet-nodes-panel').boundingBox() + const cells = overview.locator('.fleet-overview__cell') + await expect(cells).toHaveCount(5) + const cellTops = await cells.evaluateAll(items => items.map(cell => Math.round(cell.getBoundingClientRect().top))) + expect(new Set(cellTops).size).toBe(1) + const attention = page.getByRole('complementary', { name: 'Attention queue' }) + await expect(attention).toBeVisible() + const overviewBox = await overview.boundingBox() + const attentionBox = await attention.boundingBox() + expect(attentionBox.y).toBeGreaterThanOrEqual(overviewBox.y + overviewBox.height) + + const checkbox = page.getByRole('checkbox', { name: 'Select atlas' }) + await checkbox.check() + await expect(page.getByRole('row', { name: /atlas/ })).toHaveClass(/is-selected/) + await expect(page.locator('.fleet-bulkbar')).toBeVisible() + await expect(page.getByRole('button', { name: 'Clear selection' })).toBeVisible() + + const inspectNode = page.getByRole('button', { name: 'Inspect atlas' }) + await inspectNode.focus() + await inspectNode.press('Enter') + const inspector = page.getByRole('complementary', { name: 'Node inspector' }) + await expect(inspector).toBeVisible() + await expect(inspector.getByRole('heading', { name: 'Node' })).toBeVisible() + await expect(inspector.getByRole('heading', { name: 'Resources' })).toBeVisible() + await expect(inspector.getByRole('heading', { name: 'Workload' })).toBeVisible() + await expect(inspector.locator('.node-inspector__resource')).toHaveCount(2) + + const overviewAfter = await overview.boundingBox() + const fleetAfter = await page.locator('#fleet-nodes-panel').boundingBox() + expect(Math.abs(overviewAfter.width - overviewBefore.width)).toBeLessThanOrEqual(1) + expect(Math.abs(fleetBefore.width - fleetAfter.width)).toBeLessThanOrEqual(1) + await expect(inspector).toHaveCSS('position', 'fixed') + await expect.poll(async () => { + const inspectorBox = await inspector.boundingBox() + return Math.max( + Math.abs(inspectorBox.x + inspectorBox.width - 1584), + Math.abs(inspectorBox.y - 16), + Math.abs(inspectorBox.height - 1018), + ) + }).toBeLessThanOrEqual(1) + }) + + test('reflows the overview and presents a contained drawer at a narrow viewport', async ({ page }) => { + await page.setViewportSize({ width: 640, height: 900 }) + await mockNodes(page, [baseNodes[0]]) + await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.goto('/app/nodes') + + const overview = page.getByRole('region', { name: 'Fleet overview' }) + await expect(overview).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('.console-layout > .console-rail')).toBeVisible() + const overviewBox = await overview.boundingBox() + expect(overviewBox.width).toBeGreaterThan(500) + const cells = overview.locator('.fleet-overview__cell') + const tops = await cells.evaluateAll(items => items.map(item => Math.round(item.getBoundingClientRect().top))) + expect(new Set(tops).size).toBeGreaterThan(1) + await expect(overview.getByLabel('Fleet health summary')).toHaveCSS('grid-column-start', '1') + await expect(overview.getByLabel('Fleet health summary')).toHaveCSS('grid-column-end', '-1') + + const narrowInspectNode = page.getByRole('button', { name: 'Inspect atlas' }) + await narrowInspectNode.focus() + await narrowInspectNode.press('Enter') + const inspector = page.getByRole('dialog', { name: 'Node inspector' }) + await expect(inspector).toBeVisible() + await expect(inspector).toHaveAttribute('aria-modal', 'true') + await expect(inspector).toHaveCSS('position', 'fixed') + await expect(page.locator('.node-inspector__scrim')).toBeVisible() + await expect(page.locator('body')).toHaveCSS('overflow', 'hidden') + await expect(page.getByRole('region', { name: 'Fleet workbench', includeHidden: true })).toHaveAttribute('inert', '') + await expect(page.getByRole('region', { name: 'Fleet workbench', includeHidden: true })).toHaveAttribute('aria-hidden', 'true') + const workbenchBox = await page.locator('.fleet-workbench').boundingBox() + expect(workbenchBox.width).toBeLessThanOrEqual(600) + await expect.poll(async () => (await inspector.boundingBox()).y).toBeLessThanOrEqual(1) + const inspectorBox = await inspector.boundingBox() + expect(inspectorBox.height).toBe(900) + const close = inspector.getByRole('button', { name: 'Close node inspector' }) + await expect(close).toBeFocused() + await close.press('Shift+Tab') + await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeFocused() + await page.keyboard.press('Tab') + await expect(close).toBeFocused() + await page.keyboard.press('Escape') + await expect(inspector).toHaveCount(0) + await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') + await expect(page.getByRole('region', { name: 'Fleet workbench' })).not.toHaveAttribute('inert', '') + await expect(page.getByRole('region', { name: 'Fleet workbench' })).not.toHaveAttribute('aria-hidden', 'true') + await expect(narrowInspectNode).toBeFocused() + }) + + test('loads running models once on activation and drills model to node and back', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 1050 }) + await mockNodes(page, baseNodes.map(node => node.id === 'n2' ? { ...node, status: 'healthy' } : node)) + let modelRequests = 0 + let backendRequests = 0 + await page.route('**/api/nodes/models', route => { + modelRequests += 1 + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) }) + }) + await page.route('**/api/nodes/n1/backends', route => { + backendRequests += 1 + return route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"}]' }) + }) + await page.goto('/app/nodes') + await expect(page.getByRole('table', { name: 'Fleet nodes' })).toBeVisible({ timeout: 15_000 }) + expect(modelRequests).toBe(0) + expect(backendRequests).toBe(0) + + const nodesTab = page.getByRole('tab', { name: 'Nodes' }) + const modelsTab = page.getByRole('tab', { name: 'Running models' }) + const nodesPanel = page.locator('#fleet-nodes-panel') + const modelsPanel = page.locator('#fleet-models-panel') + await expect(nodesTab).toHaveAttribute('aria-controls', 'fleet-nodes-panel') + await expect(nodesTab).toHaveAttribute('tabindex', '0') + await expect(modelsTab).toHaveAttribute('aria-controls', 'fleet-models-panel') + await expect(modelsTab).toHaveAttribute('tabindex', '-1') + await expect(nodesPanel).toHaveAttribute('role', 'tabpanel') + await expect(nodesPanel).toHaveAttribute('aria-labelledby', 'fleet-nodes-tab') + await expect(nodesPanel).not.toHaveAttribute('hidden', '') + await expect(nodesPanel).toBeVisible() + await expect(modelsPanel).toHaveAttribute('role', 'tabpanel') + await expect(modelsPanel).toHaveAttribute('aria-labelledby', 'fleet-models-tab') + await expect(modelsPanel).toHaveAttribute('hidden', '') + await expect(modelsPanel).toBeHidden() + await expect(modelsPanel.getByRole('table', { name: 'Running models' })).toHaveCount(0) + await expect(nodesPanel.locator('tbody tr')).toHaveCount(3) + + await nodesTab.focus() + await nodesTab.press('ArrowRight') + await expect(modelsTab).toBeFocused() + await expect(modelsTab).toHaveAttribute('aria-selected', 'true') + await expect(modelsTab).toHaveAttribute('tabindex', '0') + await expect(nodesTab).toHaveAttribute('tabindex', '-1') + await expect(nodesPanel).toHaveAttribute('hidden', '') + await expect(nodesPanel).toBeHidden() + await expect(modelsPanel).not.toHaveAttribute('hidden', '') + await expect(modelsPanel).toBeVisible() + await expect(page.getByText('Current loaded replicas on healthy nodes')).toBeVisible() + await expect(page.getByRole('table', { name: 'Running models' })).toBeVisible() + await expect(page.getByRole('row', { name: /Llama 3.2/ })).toContainText('3') + expect(modelRequests).toBe(1) + expect(backendRequests).toBe(0) + + const modelControl = page.getByRole('button', { name: 'Inspect Llama 3.2' }) + await expect(modelControl).not.toHaveAttribute('aria-selected') + await expect(modelControl).toHaveAttribute('aria-pressed', 'false') + await expect(modelControl).toHaveAttribute('aria-expanded', 'false') + await expect(modelControl).not.toHaveAttribute('aria-controls') + await modelControl.focus() + await modelControl.press('Enter') + const modelInspector = page.getByRole('complementary', { name: 'Model inspector' }) + const closeModel = page.getByRole('button', { name: 'Close model inspector' }) + await expect(closeModel).toBeFocused() + await expect(modelControl).not.toHaveAttribute('aria-selected') + await expect(modelControl).toHaveAttribute('aria-pressed', 'true') + await expect(modelControl).toHaveAttribute('aria-expanded', 'true') + await expect(modelControl).toHaveAttribute('aria-current', 'true') + await expect(modelControl).toHaveAttribute('aria-controls', 'model-inspector') + await expect(modelInspector).toContainText('2 replicas') + await expect(modelInspector).toContainText('borealis') + const atlasControl = modelInspector.getByRole('button', { name: /Open node atlas/ }) + await atlasControl.focus() + await atlasControl.press('Enter') + await expect(page.getByRole('complementary', { name: 'Node inspector' })).toBeVisible() + await expect(modelControl).toHaveAttribute('aria-pressed', 'true') + await expect(modelControl).toHaveAttribute('aria-expanded', 'false') + await expect(modelControl).not.toHaveAttribute('aria-controls') + const backToModel = page.getByRole('button', { name: 'Back to Llama 3.2' }) + await expect(backToModel).toBeFocused() + await expect.poll(() => backendRequests).toBe(1) + expect(modelRequests).toBe(1) + await backToModel.press('Enter') + await expect(page.getByRole('complementary', { name: 'Model inspector' })).toBeVisible() + await expect(page.getByRole('button', { name: /Open node atlas/ })).toBeFocused() + await closeModel.click() + await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0) + await expect(modelControl).toBeFocused() + await expect(modelControl).not.toHaveAttribute('aria-selected') + await expect(modelControl).toHaveAttribute('aria-pressed', 'false') + await expect(modelControl).toHaveAttribute('aria-expanded', 'false') + await expect(modelControl).not.toHaveAttribute('aria-controls') + + await page.getByRole('button', { name: 'Inspect Whisper large v3' }).click() + await expect(page.getByRole('complementary', { name: 'Model inspector' })).toContainText('missing') + await expect(page.getByRole('complementary', { name: 'Model inspector' })).toContainText('Unknown') + await page.getByRole('button', { name: 'Close model inspector' }).click() + + await modelsTab.focus() + await modelsTab.press('ArrowLeft') + await expect(nodesTab).toBeFocused() + await expect(nodesTab).toHaveAttribute('aria-selected', 'true') + await expect(nodesPanel).not.toHaveAttribute('hidden', '') + await expect(modelsPanel).toHaveAttribute('hidden', '') + await expect(modelsPanel.locator('tbody tr')).toHaveCount(2) + await nodesTab.press('ArrowRight') + await expect(modelsPanel.locator('tbody tr')).toHaveCount(2) + expect(modelRequests).toBe(1) + }) + + test('treats the model inspector as a modal drawer on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await mockNodes(page) + await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) })) + await page.goto('/app/nodes') + await page.getByRole('tab', { name: 'Running models' }).click() + await page.getByRole('button', { name: 'Inspect Llama 3.2' }).click() + + const inspector = page.getByRole('dialog', { name: 'Model inspector' }) + await expect(inspector).toHaveAttribute('aria-modal', 'true') + await expect(inspector.getByRole('button', { name: 'Close model inspector' })).toBeFocused() + await expect(page.locator('.fleet-workbench')).toHaveAttribute('inert', '') + await inspector.getByRole('button', { name: 'Close model inspector' }).press('Shift+Tab') + await expect(inspector.getByRole('button', { name: 'Close', exact: true })).toBeFocused() + await page.keyboard.press('Tab') + await expect(inspector.getByRole('button', { name: 'Close model inspector' })).toBeFocused() + }) + + test('keeps the node inspector open when Escape dismisses its confirmation dialog', async ({ page }) => { + await mockNodes(page) + await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.goto('/app/nodes') + await page.getByRole('checkbox', { name: 'Select atlas' }).check() + await page.getByRole('button', { name: 'Inspect atlas' }).click() + const inspector = page.getByRole('complementary', { name: 'Node inspector' }) + await expect(inspector).toBeVisible() + + await page.getByRole('button', { name: 'Remove selected' }).click() + await expect(page.getByRole('alertdialog')).toBeVisible() + await page.keyboard.press('Escape') + + await expect(page.getByRole('alertdialog')).toHaveCount(0) + await expect(inspector).toBeVisible() + }) + + test('stops a running model once from an accessible row menu and refreshes inventory', async ({ page }) => { + await mockNodes(page) + let modelRequests = 0 + let stopRequests = 0 + let stopBody + let finishStop + await page.route('**/api/nodes/models', route => { + modelRequests += 1 + const rows = modelRequests === 1 ? baseModels : baseModels.filter(row => row.model_name !== 'Llama 3.2') + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rows) }) + }) + await page.route('**/backend/shutdown', async route => { + stopRequests += 1 + stopBody = route.request().postDataJSON() + await new Promise(resolve => { finishStop = resolve }) + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' }) + }) + await page.goto('/app/nodes') + const modelsTab = page.getByRole('tab', { name: 'Running models' }) + await modelsTab.click() + + const trigger = page.getByRole('button', { name: 'Actions for Llama 3.2' }) + await expect(trigger).toBeVisible() + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + await expect(modelsTab).toBeFocused() + + await trigger.focus() + await trigger.press('Enter') + const menu = page.getByRole('menu', { name: 'Llama 3.2 actions' }) + await expect(menu).toBeVisible() + await expect(menu).toBeFocused() + await menu.press('Escape') + await expect(menu).toHaveCount(0) + await expect(trigger).toBeFocused() + await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0) + + await trigger.click() + await page.locator('.model-workbench__scope').click() + await expect(menu).toHaveCount(0) + await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0) + + await trigger.click() + await menu.getByRole('menuitem', { name: 'Stop model…' }).click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Stop Llama 3.2?') + await expect(dialog).toContainText('Llama 3.2 has 3 loaded replicas across 2 unique nodes. This will stop all loaded placements on those nodes.') + await expect(dialog.getByRole('button', { name: 'Stop model' })).toBeFocused() + await page.keyboard.press('Tab') + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused() + await page.keyboard.press('Shift+Tab') + await expect(dialog.getByRole('button', { name: 'Stop model' })).toBeFocused() + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toHaveCount(0) + await expect(trigger).toBeFocused() + + await trigger.click() + await menu.getByRole('menuitem', { name: 'Stop model…' }).click() + await dialog.getByRole('button', { name: 'Stop model' }).evaluate(button => { + button.click() + button.click() + }) + + await expect(dialog.getByRole('button', { name: 'Stopping…' })).toBeDisabled() + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeDisabled() + await expect.poll(() => stopRequests).toBe(1) + expect(stopBody).toEqual({ model: 'Llama 3.2' }) + finishStop() + await expect.poll(() => modelRequests).toBe(2) + await expect(page.getByText('Stopped Llama 3.2: 3 replicas across 2 nodes.')).toBeVisible() + await expect(page.getByRole('button', { name: 'Inspect Llama 3.2' })).toHaveCount(0) + }) + + test('refreshes model inventory and warns about partial shutdown after a stop failure', async ({ page }) => { + await mockNodes(page) + let modelRequests = 0 + let stopRequests = 0 + await page.route('**/api/nodes/models', route => { + modelRequests += 1 + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) }) + }) + await page.route('**/backend/shutdown', route => { + stopRequests += 1 + return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"controller timed out"}' }) + }) + await page.goto('/app/nodes') + await page.getByRole('tab', { name: 'Running models' }).click() + await page.getByRole('button', { name: 'Actions for Whisper large v3' }).click() + await page.getByRole('menuitem', { name: 'Stop model…' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Stop model' }).click() + + await expect.poll(() => stopRequests).toBe(1) + await expect.poll(() => modelRequests).toBe(2) + await expect(page.getByText(/Could not stop Whisper large v3:.*Some replicas may already have stopped\./)).toBeVisible() + await expect(page.getByRole('button', { name: 'Inspect Whisper large v3' })).toBeVisible() + }) + + test('moves focus into and restores it from the node inspector', async ({ page }) => { + await mockNodes(page, [baseNodes[0]]) + await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.goto('/app/nodes') + + const nodeControl = page.getByRole('button', { name: 'Inspect atlas' }) + await nodeControl.click() + const closeNode = page.getByRole('button', { name: 'Close node inspector' }) + await expect(closeNode).toBeFocused() + await closeNode.click() + await expect(nodeControl).toBeFocused() + }) + + test('keeps drawer actions visible for a one-row filtered fleet', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }) + await mockNodes(page, baseNodes) + await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"}]' })) + await page.goto('/app/nodes') + await page.getByRole('searchbox', { name: 'Search nodes' }).fill('atlas') + await expect(page.getByRole('row', { name: /atlas/ })).toBeVisible() + await page.getByRole('button', { name: 'Inspect atlas' }).click() + + const inspector = page.getByRole('complementary', { name: 'Node inspector' }) + await expect(inspector.getByRole('heading', { name: 'Resources' })).toBeVisible() + await expect(inspector.getByRole('heading', { name: 'Workload' })).toBeVisible() + await expect(inspector.getByRole('link', { name: 'Open full node details' })).toBeVisible() + await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeVisible() + + const inspectorBox = await inspector.boundingBox() + expect(Math.abs(inspectorBox.y - 16)).toBeLessThanOrEqual(1) + expect(Math.abs(inspectorBox.height - 868)).toBeLessThanOrEqual(1) + await expect(inspector.locator('.node-inspector__actions')).toHaveCSS('display', 'grid') + }) + + test('keeps a desktop drawer in the visible viewport after opening from a long roster', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + const nodes = Array.from({ length: 50 }, (_, index) => ({ + id: `long-${index}`, + name: `long-worker-${String(index).padStart(2, '0')}`, + node_type: 'backend', + status: 'healthy', + })) + await mockNodes(page, nodes) + await page.route('**/api/nodes/long-49/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.goto('/app/nodes') + + await page.getByRole('button', { name: 'Inspect long-worker-49' }).click() + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(1000) + const inspector = page.getByRole('complementary', { name: 'Node inspector' }) + await expect(inspector).toHaveCSS('position', 'fixed') + await expect(inspector.getByRole('heading', { name: 'long-worker-49' })).toBeVisible() + await expect(inspector.getByRole('link', { name: 'Open full node details' })).toBeVisible() + const box = await inspector.boundingBox() + expect(Math.abs(box.y - 16)).toBeLessThanOrEqual(1) + expect(Math.abs(box.height - 768)).toBeLessThanOrEqual(1) + }) + + test('shows model loading, error, retry, and empty states', async ({ page }) => { + await mockNodes(page) + let finishFirst + let attempts = 0 + await page.route('**/api/nodes/models', async route => { + attempts += 1 + if (attempts === 1) { + await new Promise(resolve => { finishFirst = resolve }) + return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"database unavailable"}' }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) + }) + await page.goto('/app/nodes') + await page.getByRole('tab', { name: 'Running models' }).click() + await expect(page.getByText('Loading running models…')).toBeVisible() + finishFirst() + await expect(page.getByText('Unable to load running models')).toBeVisible() + await page.getByRole('button', { name: 'Retry loading running models' }).click() + await expect(page.getByText('No running models')).toBeVisible() + expect(attempts).toBe(2) + }) + + test('mounts 50 of 1,000 running models and supports search and sorting', async ({ page }) => { + await mockNodes(page) + const models = Array.from({ length: 1000 }, (_, index) => ({ + id: `replica-${index}`, + node_id: 'n1', + model_name: `model-${String(index).padStart(4, '0')}`, + replica_index: 0, + address: `10.0.0.1:${51000 + index}`, + state: 'loaded', + in_flight: index % 7, + backend_type: 'llama-cpp', + last_used: new Date(Date.UTC(2026, 8, 1, 0, index)).toISOString(), + })) + await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(models) })) + await page.goto('/app/nodes') + await page.getByRole('tab', { name: 'Running models' }).click() + await expect(page.getByRole('table', { name: 'Running models' }).locator('tbody tr')).toHaveCount(50) + await expect(page.getByText('Page 1 of 20')).toBeVisible() + await page.getByRole('button', { name: 'Next model page' }).click() + await expect(page.getByText('Page 2 of 20')).toBeVisible() + await page.getByRole('searchbox', { name: 'Search running models' }).fill('model-0000') + await expect(page.locator('#fleet-models-panel').getByText('Page 1 of 1')).toBeVisible() + await page.getByRole('searchbox', { name: 'Search running models' }).fill('') + await page.getByRole('button', { name: /Sort by model/ }).click() + await expect(page.getByRole('table', { name: 'Running models' }).locator('tbody tr').first()).toContainText('model-0999') + }) + +}) diff --git a/core/http/react-ui/e2e/nodes-roster.spec.js b/core/http/react-ui/e2e/nodes-roster.spec.js index c6396d5b3..721f99766 100644 --- a/core/http/react-ui/e2e/nodes-roster.spec.js +++ b/core/http/react-ui/e2e/nodes-roster.spec.js @@ -1,64 +1,35 @@ import { test, expect } from './coverage-fixtures.js' async function mockCluster(page, nodes) { - await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(nodes) })) - await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) - await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.route('**/api/nodes', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(nodes) })) } -test.describe('Nodes roster header', () => { - test('shows a cluster pulse line and no stat-card grid', async ({ page }) => { +test.describe('Nodes fleet roster', () => { + test('uses the fleet response without prefetching models or backends', async ({ page }) => { + const requests = [] + page.on('request', request => requests.push(request.url())) await mockCluster(page, [ - { id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' }, - { id: 'n2', name: 'beta', node_type: 'backend', address: '10.0.0.2:50051', status: 'draining' }, + { id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', model_count: 3 }, + { id: 'a1', name: 'agent-1', node_type: 'agent', address: '10.0.0.9:50051', status: 'draining', model_count: 0 }, ]) await page.goto('/app/nodes') - await expect(page.locator('.cluster-pulse')).toBeVisible({ timeout: 15_000 }) - await expect(page.locator('.cluster-pulse')).toContainText('2 nodes') - await expect(page.locator('.stat-grid')).toHaveCount(0) + await expect(page.getByRole('table', { name: 'Fleet nodes' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('tab', { name: 'Nodes' })).toHaveAttribute('aria-selected', 'true') + await page.getByRole('tab', { name: 'Nodes' }).click() + await expect(page.getByRole('row', { name: /alpha/ })).toContainText('3') + expect(requests.some(url => url.includes('/api/nodes/models'))).toBe(false) + expect(requests.some(url => /\/api\/nodes\/[^/]+\/backends/.test(url))).toBe(false) }) - test('shows an approval callout for pending nodes', async ({ page }) => { - await mockCluster(page, [{ id: 'n3', name: 'gamma', node_type: 'backend', address: '10.0.0.3:50051', status: 'pending' }]) + test('preserves the empty worker setup experience', async ({ page }) => { + await mockCluster(page, []) await page.goto('/app/nodes') - await expect(page.locator('.attention-callout')).toContainText('approval', { timeout: 15_000 }) - }) -}) - -test.describe('Nodes roster panels', () => { - test('shows used and total system RAM reported by a worker', async ({ page }) => { - await mockCluster(page, [ - { - id: 'n1', - name: 'alpha', - node_type: 'backend', - address: '10.0.0.1:50051', - status: 'healthy', - total_ram: 8_000_000_000, - available_ram: 3_000_000_000, - }, - ]) - - await page.goto('/app/nodes') - await expect(page.locator('.node-panel').filter({ hasText: 'alpha' })).toContainText('RAM 4.7 GB / 7.5 GB', { timeout: 15_000 }) - }) - - test('shows model chips without clicking and filters by type', async ({ page }) => { - await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ - { id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' }, - { id: 'a1', name: 'agent-1', node_type: 'agent', address: '10.0.0.9:50051', status: 'healthy' }, - ]) })) - await page.route('**/api/nodes/models', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ - { node_id: 'n1', model_name: 'llama-3.3', state: 'loaded', in_flight: 2, replica_index: 0 }, - ]) })) - await page.route('**/api/nodes/scheduling', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) - - await page.goto('/app/nodes') - // model chip visible without any expand click - await expect(page.locator('.node-panel').filter({ hasText: 'alpha' }).getByText('llama-3.3')).toBeVisible({ timeout: 15_000 }) - // segmented filter: Agent shows the agent node, hides the backend node - await page.getByRole('radio', { name: /Agent/ }).click() - await expect(page.getByText('agent-1')).toBeVisible() - await expect(page.getByText('alpha')).toHaveCount(0) + await expect(page.getByText('No workers registered yet')).toBeVisible({ timeout: 15_000 }) + }) + + test('preserves the distributed-disabled setup experience', async ({ page }) => { + await page.route('**/api/nodes', route => route.fulfill({ status: 503, body: 'Service Unavailable' })) + await page.goto('/app/nodes') + await expect(page.getByText('Distributed Mode Not Enabled')).toBeVisible({ timeout: 15_000 }) }) }) diff --git a/core/http/react-ui/inline-style-baseline.txt b/core/http/react-ui/inline-style-baseline.txt index a08796291..4d0e90cbc 100644 --- a/core/http/react-ui/inline-style-baseline.txt +++ b/core/http/react-ui/inline-style-baseline.txt @@ -1 +1 @@ -514 +512 diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 679e16522..853e5d4c9 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -382,7 +382,6 @@ padding-left: var(--spacing-md); padding-right: var(--spacing-md); } - .nav-external { font-size: 0.55rem; margin-left: auto; @@ -9062,8 +9061,8 @@ button.collapsible-header:focus-visible { } .console-rail-header__title { display: inline-flex; align-items: center; gap: var(--spacing-sm); } .console-rail-header__title i { color: var(--color-primary); font-size: 0.9rem; } -.console-rail-toggle { - display: none; +.console-rail-toggle, +.console-rail-collapse { width: 34px; height: 34px; place-items: center; @@ -9073,7 +9072,10 @@ button.collapsible-header:focus-visible { color: var(--color-text-secondary); cursor: pointer; } -.console-rail-toggle:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); } +.console-rail-toggle { display: none; } +.console-rail-collapse { display: grid; } +.console-rail-toggle:hover, +.console-rail-collapse:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); } .console-rail-groups { display: flex; flex-direction: column; gap: var(--spacing-xs); } .console-group { display: flex; flex-direction: column; gap: 1px; } .console-group + .console-group { @@ -9101,14 +9103,78 @@ button.collapsible-header:focus-visible { .console-rail .nav-item:hover:not(.active) { transform: translateX(2px); } .console-rail .nav-item.active { box-shadow: none; } .console-rail .nav-item.active .nav-icon { color: var(--color-primary); } +.console-rail--collapsed { + flex-basis: 60px; + width: 60px; + padding-inline: 6px; +} +.console-rail--collapsed .console-rail-header { + justify-content: center; + padding-inline: 0; +} +.console-rail--collapsed .console-rail-header__title, +.console-rail--collapsed .console-group-title, +.console-rail--collapsed .nav-label, +.console-rail--collapsed .nav-external { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.console-rail--collapsed .console-group + .console-group { + margin-top: var(--spacing-xs); + padding-top: var(--spacing-xs); +} +.console-rail--collapsed .nav-item { + justify-content: center; + min-height: 38px; + padding: 7px; +} +.console-rail--collapsed .nav-icon { width: auto; } +.console-rail--collapsed .nav-signal { + position: absolute; + top: 1px; + right: 1px; + min-width: 14px; + padding: 1px 3px; + font-size: 0.5rem; + line-height: 1.2; +} .console-body { flex: 1 1 auto; min-width: 0; + width: 100%; } @media (max-width: 768px) { .console-layout { flex-direction: column; padding: var(--spacing-sm); } - .console-rail { position: static; flex-basis: auto; width: 100%; } + .console-rail, + .console-rail--collapsed { position: static; flex-basis: auto; width: 100%; padding: var(--spacing-sm); } + .console-rail--collapsed .console-rail-header { justify-content: space-between; padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-xs); } + .console-rail--collapsed .console-rail-header__title, + .console-rail--collapsed .console-group-title, + .console-rail--collapsed .nav-label, + .console-rail--collapsed .nav-external { + position: static; + width: auto; + height: auto; + padding: revert; + margin: 0; + overflow: visible; + clip: auto; + white-space: nowrap; + border: 0; + } + .console-rail--collapsed .console-group-title { padding: var(--spacing-xs) var(--spacing-sm); } + .console-rail--collapsed .nav-item { justify-content: flex-start; min-height: 44px; padding: 7px var(--spacing-sm); } + .console-rail--collapsed .nav-icon { width: 18px; } + .console-rail--collapsed .nav-signal { position: static; min-width: 0; padding: 1px 6px; font-size: var(--text-xs); line-height: inherit; } .console-rail-toggle { display: grid; } + .console-rail-collapse { display: none; } .console-rail-groups { display: none; } /* Thirteen destinations stacked in one column is taller than a phone, so opening the menu used to push the page's own heading past the fold: the @@ -9677,6 +9743,202 @@ button.collapsible-header:focus-visible { .model-chip__state { opacity: 0.85; font-style: normal; } .node-filter { margin-bottom: var(--spacing-lg); } .node-detail__metrics { display: flex; gap: var(--spacing-xl); margin: var(--spacing-md) 0 var(--spacing-lg); flex-wrap: wrap; } +.node-detail__metric-note { display: block; color: var(--color-text-muted); font-size: var(--text-xs); margin-top: 2px; } + +/* Nodes fleet operations dashboard */ +.nodes-fleet-page { container-name: fleet-page; container-type: inline-size; isolation: isolate; position: relative; } +.nodes-fleet-page--inspecting { container-type: normal; } +.page-transition:has(.nodes-fleet-page--inspecting) { animation: none !important; transform: none !important; } +.nodes-fleet-page__header { align-items: flex-start; margin-bottom: 18px; } +.nodes-fleet-page__header .page-title { font-size: 1.75rem; letter-spacing: -.03em; } +.nodes-fleet-page__header .page-header__supporting { font-size: var(--text-xs); margin-top: 3px; } +.fleet-kicker { display: block; color: var(--color-text-muted); font-size: .625rem; font-weight: 650; letter-spacing: .08em; margin-bottom: 4px; text-transform: uppercase; } +.fleet-overview { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-lg); + display: grid; + grid-template-columns: minmax(265px, 1.45fr) repeat(4, minmax(145px, .8fr)); + margin-bottom: 12px; + overflow: hidden; +} +.fleet-overview__cell { height: 204px; min-width: 0; padding: 22px 20px; } +.fleet-overview__cell + .fleet-overview__cell { border-left: 1px solid var(--color-border-subtle); } +.fleet-health__headline { align-items: baseline; display: flex; gap: 8px; margin: 10px 0 22px; white-space: nowrap; } +.fleet-health__headline strong { font-size: 1.625rem; letter-spacing: -.025em; } +.fleet-health__headline span { color: var(--color-text-muted); font-size: .75rem; } +.fleet-health__bar { display: block; height: 11px; margin-bottom: 17px; width: 100%; } +.fleet-health__segment--healthy { fill: var(--color-success); } +.fleet-health__segment--draining, .fleet-health__segment--pending { fill: var(--color-warning); } +.fleet-health__segment--unhealthy { fill: var(--color-error); } +.fleet-health__legend { display: grid; gap: 8px; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.fleet-health__legend > div { min-width: 0; } +.fleet-health__legend span { align-items: center; color: var(--color-text-muted); display: flex; font-size: .625rem; gap: 6px; white-space: nowrap; } +.fleet-health__legend strong { display: block; font-size: .75rem; margin-top: 4px; white-space: nowrap; } +.fleet-health__dot { background: var(--color-error); border-radius: 50%; display: inline-block; height: 7px; width: 7px; } +.fleet-health__dot--healthy { background: var(--color-success); } +.fleet-health__dot--draining { background: var(--color-warning); } +.fleet-gauge { align-content: start; display: grid; justify-items: center; } +.fleet-gauge .fleet-kicker { justify-self: start; white-space: nowrap; } +.fleet-gauge__graphic { height: 73px; margin-top: 18px; position: relative; width: 136px; } +.fleet-gauge__graphic svg { height: 73px; overflow: visible; width: 136px; } +.fleet-gauge__graphic path { fill: none; stroke-linecap: butt; stroke-width: 7; } +.fleet-gauge__track { stroke: var(--color-bg-tertiary); } +.fleet-gauge__value { stroke: var(--fleet-gauge-color); } +.fleet-gauge--vram { --fleet-gauge-color: var(--color-success); } +.fleet-gauge--ram { --fleet-gauge-color: var(--color-primary); } +.fleet-gauge--cpu { --fleet-gauge-color: var(--color-info); } +.fleet-gauge--disk { --fleet-gauge-color: var(--color-info); } +.fleet-gauge__graphic strong { bottom: 0; font-size: 1rem; left: 0; position: absolute; text-align: center; width: 136px; } +.fleet-gauge__value-text { font-family: var(--font-mono); font-size: .6875rem; font-weight: 600; margin-top: 4px; text-align: center; white-space: nowrap; } +.fleet-gauge__detail, .fleet-gauge__coverage { color: var(--color-text-muted); font-size: .5625rem; line-height: 1.35; margin-top: 3px; text-align: center; } +.fleet-gauge__coverage { margin-top: 1px; } +.fleet-attention { align-items: center; background: var(--color-warning-light); border: 1px solid var(--color-warning-border); border-radius: var(--radius-md); display: flex; gap: 18px; margin-bottom: 24px; min-height: 52px; padding: 8px 15px; } +.fleet-attention__title { align-items: center; display: flex; flex: 0 0 auto; font-size: var(--text-xs); gap: 9px; } +.fleet-attention__title i { color: var(--color-warning); } +.fleet-attention__filters { align-items: center; display: flex; flex: 1; flex-wrap: wrap; gap: 6px; justify-content: flex-end; } +.fleet-attention__filter { align-items: center; background: transparent; border: 1px solid transparent; border-radius: var(--radius-full); color: var(--color-text-secondary); cursor: pointer; display: flex; font: inherit; font-size: .625rem; gap: 6px; min-height: 28px; padding: 3px 9px; text-align: left; } +.fleet-attention__filter:hover, .fleet-attention__filter.is-active { color: var(--color-primary); } +.fleet-attention__filter:hover { background: var(--color-bg-hover); } +.fleet-attention__filter.is-active { background: var(--color-bg-secondary); border-color: var(--color-border-strong); box-shadow: 0 1px 2px rgba(0, 0, 0, .12); } +.fleet-attention__filter strong { color: var(--color-text-primary); } +.fleet-workbench { background: var(--color-bg-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-lg); container-name: fleet-workbench; container-type: inline-size; overflow: hidden; } +.fleet-workbench__tabs { align-items: stretch; background: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border-subtle); display: flex; min-height: 43px; padding: 0 11px; } +.fleet-workbench__tabs button { align-items: center; background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--color-text-muted); cursor: pointer; display: flex; font: inherit; font-size: var(--text-xs); font-weight: 600; gap: 7px; margin-bottom: -1px; padding: 0 11px; } +.fleet-workbench__tabs button:hover { color: var(--color-text-primary); } +.fleet-workbench__tabs button:focus-visible { border-radius: var(--radius-sm); box-shadow: inset 0 0 0 2px var(--color-primary); outline: none; } +.fleet-workbench__tabs button.is-active { border-bottom-color: var(--color-primary); color: var(--color-text-primary); } +.fleet-workbench__tabs button span { background: var(--color-bg-tertiary); border-radius: var(--radius-full); color: var(--color-text-muted); font-family: var(--font-mono); font-size: .5625rem; min-width: 19px; padding: 2px 6px; text-align: center; } +.fleet-workbench__layout { min-width: 0; } +.fleet-workbench__fleet { min-width: 0; } +.fleet-toolbar { border-bottom: 1px solid var(--color-border-subtle); display: grid; grid-template-columns: minmax(190px, 1fr) repeat(3, minmax(112px, auto)); gap: 7px; padding: 10px 11px; } +.fleet-toolbar__search { height: 34px; min-width: 0; } +.fleet-select-wrap { display: block; min-width: 0; position: relative; } +.fleet-select { appearance: none; background: var(--color-bg-primary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); color: var(--color-text-secondary); cursor: pointer; font: inherit; font-size: var(--text-xs); height: 34px; padding: 0 28px 0 10px; width: 100%; } +.fleet-select:hover { border-color: var(--color-border-strong); } +.fleet-select:focus-visible { border-color: var(--color-primary); box-shadow: 0 0 0 3px var(--color-primary-light); outline: none; } +.fleet-select__chevron { color: var(--color-text-muted); font-size: .625rem; pointer-events: none; position: absolute; right: 10px; top: 12px; } +.fleet-bulkbar { align-items: center; background: var(--color-primary-light); border-bottom: 1px solid var(--color-border-strong); display: flex; flex-wrap: wrap; gap: var(--spacing-xs); min-height: 42px; padding: 5px 11px; } +.fleet-bulkbar strong { font-size: var(--text-xs); margin-right: var(--spacing-xs); } +.fleet-bulkbar__clear { background: none; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-size: var(--text-xs); } +.fleet-bulkbar__count { color: var(--color-text-muted); font-size: var(--text-xs); margin-left: auto; } +.fleet-table-wrap { overflow-x: auto; } +.fleet-table { border-collapse: collapse; font-size: var(--text-xs); min-width: 820px; width: 100%; } +.fleet-table th, .fleet-table td { border-bottom: 1px solid var(--color-border-subtle); padding: 10px 12px; text-align: left; vertical-align: middle; } +.fleet-table thead th { background: var(--color-bg-secondary); color: var(--color-text-muted); font-weight: 600; } +.fleet-table__check { width: 34px; } +.fleet-table__sort { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; font-weight: inherit; padding: 0; } +.fleet-table__row { cursor: pointer; height: 67px; transition: background var(--duration-fast) var(--ease-out, ease-out); } +.fleet-table__row:hover, .fleet-table__row:focus { background: var(--color-bg-hover); outline: none; } +.fleet-table__row.is-selected { background: var(--color-primary-light); box-shadow: inset 2px 0 var(--color-primary); } +.fleet-table__node { background: none; border: 0; color: var(--color-text-primary); cursor: pointer; display: block; font: inherit; font-weight: 600; padding: 0; text-align: left; } +.fleet-table__node + span, .fleet-table__subvalue { color: var(--color-text-muted); display: block; font-size: .625rem; margin-top: 1px; } +.fleet-table__approve { background: none; border: 0; color: var(--color-primary); cursor: pointer; display: block; font: inherit; font-size: .625rem; font-weight: 650; margin-top: 5px; padding: 0; } +.fleet-table__unknown { color: var(--color-text-muted); } +.fleet-table__resource { align-items: center; display: grid; gap: 5px; grid-template-columns: minmax(45px, 1fr) auto; min-width: 92px; } +.fleet-table__resource > span:last-child { color: var(--color-text-muted); font-family: var(--font-mono); font-size: .5625rem; white-space: nowrap; } +.fleet-table__resource-track { appearance: none; background: var(--color-bg-tertiary); border: 0; border-radius: var(--radius-full); display: block; height: 5px; overflow: hidden; width: 100%; } +.fleet-table__resource-track::-webkit-progress-bar { background: var(--color-bg-tertiary); } +.fleet-table__resource-track::-webkit-progress-value { background: var(--fleet-resource-color); border-radius: var(--radius-full); } +.fleet-table__resource-track::-moz-progress-bar { background: var(--fleet-resource-color); border-radius: var(--radius-full); } +.fleet-table__resource--vram { --fleet-resource-color: var(--color-success); } +.fleet-table__resource--ram { --fleet-resource-color: var(--color-primary); } +.fleet-table__capacity { display: grid; gap: 7px; min-width: 205px; } +.fleet-table__capacity-row { align-items: center; display: grid; gap: 8px; grid-template-columns: 38px minmax(0, 1fr); } +.fleet-table__capacity-row > b { color: var(--color-text-muted); font-size: .5625rem; font-weight: 500; } +.fleet-table__group th { background: var(--color-bg-tertiary); color: var(--color-text-primary); padding: 7px 10px; } +.fleet-table__group th, .fleet-table__group label { align-items: center; display: flex; gap: var(--spacing-xs); } +.fleet-table__group span { color: var(--color-text-muted); font-weight: 400; margin-left: auto; } +.fleet-table__empty { color: var(--color-text-muted); padding: var(--spacing-xl); text-align: center; } +.fleet-pagination { align-items: center; display: flex; gap: var(--spacing-xs); justify-content: flex-end; min-height: 45px; padding: 5px 11px; } +.fleet-pagination span { color: var(--color-text-muted); font-size: var(--text-xs); margin-right: var(--spacing-xs); } +.model-workbench__scope { align-items: center; background: var(--color-bg-tertiary); border-bottom: 1px solid var(--color-border-subtle); display: flex; justify-content: space-between; min-height: 48px; padding: 7px 12px; } +.model-workbench__scope > div { display: grid; gap: 1px; } +.model-workbench__scope strong { color: var(--color-text-primary); font-size: var(--text-sm); } +.model-workbench__scope span { color: var(--color-text-muted); font-size: .625rem; } +.model-toolbar { border-bottom: 1px solid var(--color-border-subtle); padding: 9px 11px; } +.model-toolbar .fleet-toolbar__search { max-width: 430px; width: 100%; } +.model-workbench__state { align-items: center; color: var(--color-text-muted); display: flex; flex-direction: column; gap: 6px; justify-content: center; min-height: 300px; padding: var(--spacing-xl); text-align: center; } +.model-workbench__state > i { color: var(--color-text-muted); font-size: var(--text-xl); } +.model-workbench__state strong { color: var(--color-text-primary); font-size: var(--text-base); } +.model-workbench__state span { font-size: var(--text-xs); max-width: 420px; } +.model-workbench__state .btn { margin-top: 5px; } +.model-workbench__state--error > i { color: var(--color-error); } +.model-fleet-table { min-width: 720px; } +.model-fleet-table th:first-child, .model-fleet-table td:first-child { width: 37%; } +.model-fleet-table .model-fleet-table__actions { padding-left: 4px; padding-right: 8px; text-align: right; width: 38px; } +.model-fleet-table .fleet-table__row:hover .action-menu__trigger, +.model-fleet-table .action-menu__trigger:focus-visible, +.model-fleet-table .action-menu__trigger.is-open { opacity: 1; } +.model-backend-list { display: flex; flex-wrap: wrap; gap: 4px; } +.model-backend-list > span:not(.fleet-table__unknown, .text-muted) { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-full); color: var(--color-text-secondary); font-family: var(--font-mono); font-size: .5625rem; padding: 2px 6px; } +.node-inspector__scrim { display: none; } +.node-inspector { animation: node-inspector-in 180ms var(--ease-out, ease-out) both; background: var(--color-bg-primary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-xl) 0 0 var(--radius-xl); bottom: var(--spacing-md); box-shadow: -18px 0 42px rgba(0, 0, 0, .24); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; position: fixed; right: var(--spacing-md); top: var(--spacing-md); width: 360px; z-index: 80; } +@keyframes node-inspector-in { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: translateX(0); } } +.node-inspector__header { background: linear-gradient(180deg, var(--color-bg-secondary), var(--color-bg-primary)); border-bottom: 1px solid var(--color-border-subtle); padding: 18px 22px 17px; } +.node-inspector__topbar { align-items: center; display: flex; justify-content: space-between; min-height: 30px; } +.node-inspector__topbar .fleet-kicker { margin: 0; } +.node-inspector__header h2 { font-size: 1.25rem; letter-spacing: -.025em; line-height: 1.2; margin: 9px 0 0; overflow-wrap: anywhere; } +.node-inspector__header > p, .node-inspector__identity > p { color: var(--color-text-muted); font-size: .6875rem; margin: 4px 0 9px; } +.node-inspector__identity .status-pill { margin: 0; } +.node-inspector__body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 22px; scrollbar-gutter: stable; } +.node-inspector__section { border-bottom: 1px solid var(--color-border-subtle); padding: 15px 0; } +.node-inspector__section:last-of-type { border-bottom: 0; } +.node-inspector__section h3 { color: var(--color-text-muted); font-size: .625rem; font-weight: 650; letter-spacing: .08em; margin: 0 0 9px; text-transform: uppercase; } +.node-inspector__address { color: var(--color-text-secondary); font-family: var(--font-mono); font-size: .625rem; overflow-wrap: anywhere; } +.node-inspector__metrics { display: grid; gap: 0; margin: 0; } +.node-inspector__metrics > div { display: grid; font-size: var(--text-xs); grid-template-columns: 92px 1fr; padding: 5px 0; } +.node-inspector__metrics dt { color: var(--color-text-muted); } +.node-inspector__metrics dd { margin: 0; text-align: right; } +.node-inspector__labels { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } +.node-inspector__labels > span:not(.text-muted) { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-full); color: var(--color-text-secondary); font-family: var(--font-mono); font-size: .5625rem; padding: 2px 7px; } +.node-inspector__resource { margin: 10px 0 13px; } +.node-inspector__resource-label { align-items: baseline; display: flex; font-size: var(--text-xs); justify-content: space-between; margin-bottom: 6px; } +.node-inspector__resource-label strong span, .node-inspector__resource-label > span { color: var(--color-text-muted); font-size: .625rem; font-weight: 400; } +.node-inspector__resource-track { appearance: none; background: var(--color-bg-tertiary); border: 0; border-radius: var(--radius-full); display: block; height: 7px; overflow: hidden; width: 100%; } +.node-inspector__resource-track::-webkit-progress-bar { background: var(--color-bg-tertiary); } +.node-inspector__resource-track::-webkit-progress-value { background: var(--fleet-resource-color); border-radius: var(--radius-full); } +.node-inspector__resource-track::-moz-progress-bar { background: var(--fleet-resource-color); border-radius: var(--radius-full); } +.node-inspector__resource--vram { --fleet-resource-color: var(--color-success); } +.node-inspector__resource--ram { --fleet-resource-color: var(--color-primary); } +.node-inspector__actions { background: var(--color-bg-secondary); border-top: 1px solid var(--color-border-subtle); box-shadow: 0 -8px 18px rgba(0, 0, 0, .06); display: grid; gap: var(--spacing-xs); grid-template-columns: 1fr 1fr; padding: 14px 22px; } +.node-inspector__actions--single { grid-template-columns: 1fr; } +.node-inspector__actions .btn { justify-content: center; min-width: 0; } +.node-inspector__back { align-items: center; background: transparent; border: 0; color: var(--color-primary); cursor: pointer; display: flex; font: inherit; font-size: var(--text-xs); gap: 6px; max-width: 285px; overflow: hidden; padding: 3px 0; text-overflow: ellipsis; white-space: nowrap; } +.node-inspector__back:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--color-primary); outline-offset: 3px; } +.model-inspector__backends { margin-top: 10px; } +.model-inspector__nodes { display: grid; gap: 9px; } +.model-inspector__node { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: 10px; } +.model-inspector__node-heading { align-items: center; display: flex; gap: 7px; justify-content: space-between; } +.model-inspector__node-heading button { background: transparent; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-size: var(--text-sm); font-weight: 650; overflow: hidden; padding: 0; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.model-inspector__node-heading strong { font-size: var(--text-sm); overflow-wrap: anywhere; } +.model-inspector__node > p { color: var(--color-text-muted); font-size: .625rem; margin: 5px 0 8px; } +.model-inspector__node ul { border-top: 1px solid var(--color-border-subtle); list-style: none; margin: 0; padding: 5px 0 0; } +.model-inspector__node li { align-items: baseline; display: grid; font-size: .625rem; gap: 7px; grid-template-columns: auto minmax(0, 1fr); padding-top: 4px; } +.model-inspector__node li span { color: var(--color-text-muted); } +.model-inspector__node code { color: var(--color-text-secondary); font-size: .5625rem; overflow: hidden; text-align: right; text-overflow: ellipsis; white-space: nowrap; } + +@container fleet-page (max-width: 760px) { + .fleet-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .fleet-overview__cell { min-height: 146px; } + .fleet-overview__cell + .fleet-overview__cell { border-left: 0; } + .fleet-overview__cell:nth-child(even) { border-left: 1px solid var(--color-border-subtle); } + .fleet-overview__cell:nth-child(n + 3) { border-top: 1px solid var(--color-border-subtle); } + .fleet-health { grid-column: 1 / -1; } + .fleet-health { min-height: 126px; } + .fleet-attention { align-items: flex-start; flex-direction: column; gap: 7px; } + .fleet-attention__filters { justify-content: flex-start; } + .fleet-toolbar { grid-template-columns: 1fr 1fr; } + .fleet-toolbar__search { grid-column: 1 / -1; } +} +@media (max-width: 768px) { + .node-inspector__scrim { animation: node-inspector-scrim-in 180ms ease-out both; background: rgba(7, 10, 18, .52); display: block; inset: 0; position: fixed; z-index: 79; } + .node-inspector { border-bottom: 0; border-radius: 0; border-top: 0; bottom: 0; max-width: 100vw; position: fixed; top: 0; width: min(390px, 100vw); z-index: 80; } + .node-inspector__header { padding-left: 18px; padding-right: 18px; padding-top: max(16px, env(safe-area-inset-top)); } + .node-inspector__body { padding-left: 18px; padding-right: 18px; } + .node-inspector__actions { padding-bottom: max(14px, env(safe-area-inset-bottom)); padding-left: 18px; padding-right: 18px; } +} +@keyframes node-inspector-scrim-in { from { opacity: 0; } to { opacity: 1; } } /* Rendered Markdown --------------------------------------------------------- Gallery descriptions, backend descriptions and voice notes are all diff --git a/core/http/react-ui/src/components/ActionMenu.jsx b/core/http/react-ui/src/components/ActionMenu.jsx index 55010102c..e2aea8ddd 100644 --- a/core/http/react-ui/src/components/ActionMenu.jsx +++ b/core/http/react-ui/src/components/ActionMenu.jsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect, useCallback } from 'react' +import { useRef, useState, useEffect, useCallback, useId } from 'react' import Popover from './Popover' // ActionMenu renders a kebab (three-dot) button that opens a popover with a @@ -20,6 +20,7 @@ import Popover from './Popover' // Escape — close, return focus to trigger export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, compact = false }) { const triggerRef = useRef(null) + const menuId = useId() const [open, setOpen] = useState(false) const [activeIdx, setActiveIdx] = useState(-1) @@ -48,7 +49,12 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, } const handleMenuKeyDown = (e) => { - if (e.key === 'ArrowDown') { + if (e.key === 'Escape') { + // Keep the same Escape press from also closing a surrounding inspector. + e.preventDefault() + e.stopPropagation() + close() + } else if (e.key === 'ArrowDown') { e.preventDefault() setActiveIdx(i => Math.min(interactive.length - 1, (i < 0 ? -1 : i) + 1)) } else if (e.key === 'ArrowUp') { @@ -65,7 +71,7 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, const item = interactive[activeIdx] if (item && !item.disabled) { close() - item.onClick?.() + item.onClick?.(triggerRef.current) } } } @@ -92,6 +98,7 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel,
= 0 ? `${menuId}-item-${activeIdx}` : undefined} className="action-menu" onKeyDown={handleMenuKeyDown} // Capture focus when the menu opens so arrow keys work without the @@ -118,8 +125,10 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, return (