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,