diff --git a/core/http/react-ui/e2e/nodes-detail.spec.js b/core/http/react-ui/e2e/nodes-detail.spec.js index 866894a71..6793b3b9d 100644 --- a/core/http/react-ui/e2e/nodes-detail.spec.js +++ b/core/http/react-ui/e2e/nodes-detail.spec.js @@ -15,13 +15,21 @@ test.describe('Node detail page', () => { await mockNode(page) await page.goto(`/app/nodes/${ID}`) await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 }) - await expect(page.getByText('alpha')).toBeVisible() + await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible() 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() + await expect(page.getByRole('link', { name: 'Nodes' })).toHaveAttribute('href', '/app/nodes') + await expect(page.getByRole('region', { name: 'Node resources' })).toBeVisible() + await expect(page.getByRole('region', { name: 'Running models' })).toHaveClass(/fleet-workbench/) + await expect(page.getByRole('region', { name: 'Installed backends' })).toHaveClass(/fleet-workbench/) + + await page.getByRole('button', { name: 'Actions for llama-3.3 replica 1' }).click() + await page.getByRole('menuitem', { name: 'View logs' }).click() + await expect(page).toHaveURL(/\/app\/node-backend-logs\/n1\/llama-3.3%230$/) }) test('is reachable by clicking a roster panel', async ({ page }) => { @@ -42,7 +50,8 @@ test.describe('Node detail page', () => { 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') + await page.getByRole('button', { name: 'Actions for alpha' }).click() + await expect(page.getByRole('menuitem', { name: 'Remove node…' })).toBeVisible() }) } @@ -64,7 +73,8 @@ test.describe('Node detail page', () => { 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') + await page.getByRole('button', { name: 'Actions for alpha' }).click() + await expect(page.getByRole('menuitem', { name: 'Remove node…' })).toBeVisible() }) test('renders valid totals with missing available capacity as No data', async ({ page }) => { @@ -79,4 +89,34 @@ test.describe('Node detail page', () => { await expect(page.locator('.node-detail__metrics')).toContainText('Models disk free') await expect(page.locator('.node-detail__metrics').getByText('No data')).toHaveCount(3) }) + + test('keeps model operations visible on a narrow screen', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await mockNode(page) + await page.goto(`/app/nodes/${ID}`) + + const action = page.getByRole('button', { name: 'Actions for llama-3.3 replica 1' }) + await expect(action).toBeVisible() + const box = await action.boundingBox() + expect(box.x + box.width).toBeLessThanOrEqual(390) + }) + + test('distinguishes a load failure from a missing node and retries in place', async ({ page }) => { + let attempts = 0 + await page.route(`**/api/nodes/${ID}`, route => { + attempts += 1 + if (attempts === 1) return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"controller unavailable"}' }) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: ID, name: 'alpha', node_type: 'backend', status: 'healthy', labels: {} }) }) + }) + await page.route(`**/api/nodes/${ID}/models`, route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.route(`**/api/nodes/${ID}/backends`, route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + + await page.goto(`/app/nodes/${ID}`) + const error = page.getByRole('alert') + await expect(error).toContainText('Could not load this node') + await expect(page.getByText('Node not found')).toHaveCount(0) + await error.getByRole('button', { name: 'Retry' }).click() + await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible() + expect(attempts).toBe(2) + }) }) diff --git a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js index adcb5a822..e7b0ef6e0 100644 --- a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js +++ b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js @@ -131,7 +131,7 @@ test.describe('Nodes fleet dashboard', () => { 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('checkbox', { name: 'Select page' }).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 => { @@ -293,6 +293,20 @@ test.describe('Nodes fleet dashboard', () => { await expect(page.getByRole('complementary', { name: 'Node inspector' })).toHaveCount(0) }) + test('styles fleet selection consistently and exposes the partial-page state', async ({ page }) => { + await mockNodes(page) + await page.goto('/app/nodes') + + const selectPage = page.getByRole('checkbox', { name: 'Select page' }) + const selectNode = page.getByRole('checkbox', { name: 'Select atlas' }) + await expect(selectPage).toHaveCSS('width', '18px') + await expect(selectNode).toHaveCSS('height', '18px') + await expect(selectNode).toHaveCSS('cursor', 'pointer') + + await selectNode.check() + await expect.poll(() => selectPage.evaluate(input => input.indeterminate)).toBe(true) + }) + test('keeps the low-density composition while inspecting at a desktop viewport', async ({ page }) => { await page.setViewportSize({ width: 1600, height: 1050 }) await mockNodes(page) @@ -507,6 +521,28 @@ test.describe('Nodes fleet dashboard', () => { expect(modelRequests).toBe(1) }) + test('opens logs directly for one replica and asks for placement when a model has several', async ({ page }) => { + await mockNodes(page, baseNodes.map(node => node.id === 'n2' ? { ...node, status: 'healthy' } : node)) + 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: 'Actions for Whisper large v3' }).click() + await page.getByRole('menuitem', { name: 'View logs…' }).click() + await expect(page).toHaveURL(/\/app\/node-backend-logs\/missing\/Whisper%20large%20v3%230$/) + + await page.goto('/app/nodes') + await page.getByRole('tab', { name: 'Running models' }).click() + await page.getByRole('button', { name: 'Actions for Llama 3.2' }).click() + await page.getByRole('menuitem', { name: 'View logs…' }).click() + + const inspector = page.getByRole('complementary', { name: 'Model inspector' }) + await expect(inspector).toBeVisible() + await expect(inspector.getByRole('button', { name: 'View all Llama 3.2 logs on atlas' })).toBeVisible() + await inspector.getByRole('button', { name: 'View logs for Llama 3.2 replica 1 on atlas' }).click() + await expect(page).toHaveURL(/\/app\/node-backend-logs\/n1\/Llama%203.2%230$/) + }) + test('treats the model inspector as a modal drawer on mobile', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await mockNodes(page) diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 853e5d4c9..83ce93ec3 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -8867,6 +8867,9 @@ button.collapsible-header:focus-visible { height: 24px; font-size: var(--text-xs); } +@media (hover: none) { + .action-menu__trigger { opacity: 1; } +} .action-menu { display: flex; @@ -9742,8 +9745,48 @@ button.collapsible-header:focus-visible { .model-chip__dot { width: 6px; height: 6px; border-radius: 50%; } .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; } +.node-detail__header .page-header__eyebrow { text-transform: none; } +.node-detail__breadcrumb { align-items: center; display: inline-flex; gap: 7px; } +.node-detail__breadcrumb a { color: var(--color-primary); text-decoration: none; } +.node-detail__breadcrumb a:hover { color: var(--color-primary-hover); text-decoration: underline; text-underline-offset: 2px; } +.node-detail__breadcrumb i { color: var(--color-text-muted); font-size: .5rem; } +.node-detail__identity { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; } +.node-detail__identity > span:last-child { color: var(--color-text-muted); } +.node-detail__metrics { + background: var(--color-border-subtle); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-lg); + display: grid; + gap: 1px; + grid-template-columns: repeat(6, minmax(0, 1fr)); + margin-bottom: 18px; + overflow: hidden; +} +.node-detail__metrics > div { background: var(--color-bg-secondary); min-width: 0; padding: 14px 16px; } +.node-detail__metrics .drawer-eyebrow { margin-bottom: 5px; } +.node-detail__metrics .cell-mono { font-size: var(--text-xs); overflow-wrap: anywhere; } +.node-detail__metric-note { display: block; color: var(--color-text-muted); font-size: .625rem; margin-top: 2px; } +.node-detail__layout { align-items: start; display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); } +.node-detail__workloads { display: grid; gap: 18px; min-width: 0; } +.node-detail__workbench { min-width: 0; } +.node-detail__workbench .model-workbench__scope { background: var(--color-bg-secondary); } +.node-detail__workbench .model-workbench__scope > span { white-space: nowrap; } +.node-detail__table { min-width: 570px; } +.node-detail__table tbody tr:last-child td { border-bottom: 0; } +.node-detail__mobile-meta { color: var(--color-text-muted); display: none; font-family: var(--font-sans); font-size: .625rem; margin-top: 3px; } +.node-detail__empty { align-items: center; color: var(--color-text-muted); display: flex; font-size: var(--text-xs); gap: 8px; min-height: 72px; padding: 16px; } +.node-detail__text-action { background: transparent; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-weight: 600; padding: 0; } +.node-detail__text-action:hover { color: var(--color-primary-hover); text-decoration: underline; text-underline-offset: 2px; } +.node-detail__text-action:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--color-primary); outline-offset: 3px; } +.node-detail__configuration { background: var(--color-bg-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-lg); overflow: hidden; } +.node-detail__configuration section { padding: 17px 18px; } +.node-detail__configuration section + section { border-top: 1px solid var(--color-border-subtle); } +.node-detail__configuration p { color: var(--color-text-muted); font-size: var(--text-xs); line-height: var(--leading-normal); margin: 4px 0 12px; } +.node-detail__load-error { align-items: center; background: var(--color-bg-secondary); border: 1px solid var(--color-error-border); border-radius: var(--radius-lg); display: flex; gap: 12px; padding: 16px; } +.node-detail__load-error > i { color: var(--color-error); } +.node-detail__load-error > div { display: grid; flex: 1; gap: 2px; } +.node-detail__load-error strong { font-size: var(--text-sm); } +.node-detail__load-error span { color: var(--color-text-muted); font-size: var(--text-xs); } /* Nodes fleet operations dashboard */ .nodes-fleet-page { container-name: fleet-page; container-type: inline-size; isolation: isolate; position: relative; } @@ -9826,7 +9869,20 @@ button.collapsible-header:focus-visible { .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__check { padding-left: 10px !important; padding-right: 6px !important; width: 38px; } +.fleet-checkbox { + accent-color: var(--color-primary); + cursor: pointer; + display: block; + height: 18px; + margin: 0; + width: 18px; +} +.fleet-checkbox:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 3px; +} +.fleet-checkbox:disabled { cursor: not-allowed; opacity: .5; } .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; } @@ -9912,9 +9968,16 @@ button.collapsible-header:focus-visible { .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-actions { align-items: center; display: flex; gap: 8px; } +.model-inspector__logs, +.model-inspector__replica-logs { background: transparent; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-size: .625rem; font-weight: 600; padding: 3px; white-space: nowrap; } +.model-inspector__logs:hover, +.model-inspector__replica-logs:hover { color: var(--color-primary-hover); text-decoration: underline; text-underline-offset: 2px; } +.model-inspector__logs:focus-visible, +.model-inspector__replica-logs:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--color-primary); outline-offset: 2px; } .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 { align-items: baseline; display: grid; font-size: .625rem; gap: 7px; grid-template-columns: auto minmax(0, 1fr) auto; 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; } @@ -9931,6 +9994,23 @@ button.collapsible-header:focus-visible { .fleet-toolbar { grid-template-columns: 1fr 1fr; } .fleet-toolbar__search { grid-column: 1 / -1; } } +@container fleet-page (max-width: 980px) { + .node-detail__metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .node-detail__layout { grid-template-columns: minmax(0, 1fr); } +} +@container fleet-page (max-width: 540px) { + .node-detail__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .node-detail__workbench .model-workbench__scope { align-items: flex-start; flex-direction: column; gap: 8px; padding: 10px 12px; } + .node-detail__table { min-width: 0; } + .node-detail__table--models th:nth-child(2), + .node-detail__table--models th:nth-child(3), + .node-detail__table--models td:nth-child(2), + .node-detail__table--models td:nth-child(3), + .node-detail__table--backends th:nth-child(3), + .node-detail__table--backends td:nth-child(3) { display: none; } + .node-detail__table .model-fleet-table__actions { padding-left: 4px; padding-right: 8px; text-align: right; width: 38px; } + .node-detail__mobile-meta { display: block; } +} @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; } diff --git a/core/http/react-ui/src/components/nodes/ModelFleetTable.jsx b/core/http/react-ui/src/components/nodes/ModelFleetTable.jsx index 0b825c8b5..9af0ad064 100644 --- a/core/http/react-ui/src/components/nodes/ModelFleetTable.jsx +++ b/core/http/react-ui/src/components/nodes/ModelFleetTable.jsx @@ -12,7 +12,7 @@ function SortButton({ column, label, sort, onSortChange }) { ) } -export default function ModelFleetTable({ models, selectedName, inspectorOpen, onInspect, onStop, stoppingName, sort, onSortChange }) { +export default function ModelFleetTable({ models, selectedName, inspectorOpen, onInspect, onViewLogs, onStop, stoppingName, sort, onSortChange }) { return (
@@ -45,6 +45,13 @@ export default function ModelFleetTable({ models, selectedName, inspectorOpen, o ariaLabel={`${model.model_name} actions`} triggerLabel={`Actions for ${model.model_name}`} items={[{ + key: 'logs', + icon: 'fa-terminal', + label: 'View logs…', + onClick: invoker => onViewLogs(model, invoker), + }, { + divider: true, + }, { key: 'stop', icon: 'fa-stop', label: stoppingName === model.model_name ? 'Stopping…' : 'Stop model…', diff --git a/core/http/react-ui/src/components/nodes/ModelInspector.jsx b/core/http/react-ui/src/components/nodes/ModelInspector.jsx index a9a28a62c..6c058d478 100644 --- a/core/http/react-ui/src/components/nodes/ModelInspector.jsx +++ b/core/http/react-ui/src/components/nodes/ModelInspector.jsx @@ -20,7 +20,7 @@ function latestUse(replicas) { return values.length ? values.sort((left, right) => right.time - left.time)[0].value : null } -export default function ModelInspector({ model, nodes, open, onClose, onOpenNode, focusNodeId }) { +export default function ModelInspector({ model, nodes, open, onClose, onOpenNode, onViewLogs, focusNodeId }) { const closeRef = useRef(null) const drawerRef = useRef(null) const nodeButtonRefs = useRef(new Map()) @@ -69,10 +69,24 @@ export default function ModelInspector({ model, nodes, open, onClose, onOpenNode ? : {name}} - {group.node ? : Unknown} +
+ {group.node ? : Unknown} + {group.nodeId &&
0 && selectedVisible === visibleIds.length} + 0 && selectedVisible === visibleIds.length} ref={input => { if (input) input.indeterminate = selectedVisible > 0 && selectedVisible < visibleIds.length }} onChange={event => setMany(visibleIds, event.target.checked)} />
event.stopPropagation()}> event.stopPropagation()}> setMany([node.id], event.target.checked)} /> {node.node_type || 'backend'} · {node.address || 'No address'} {nodeLifecycleAction(node.status) === 'approve' && }
{group.nodes.length} nodes · {groupSelected} selected
ModelStateIn-FlightLogsActions
- {m.model_name} - {showReplica && ( - - rep {m.replica_index ?? 0} - - )} - - - {m.state} - - - {m.in_flight ?? 0} - - { - e.preventDefault() - // Send the replica-scoped process key (modelName#replicaIndex). - navigate(`/app/node-backend-logs/${id}/${encodeURIComponent(processKey)}`) - }} - className="text-xs text-primary" - title={showReplica ? `View backend logs for replica ${m.replica_index ?? 0}` : 'View backend logs'} - > - - - - -