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
? { if (element) nodeButtonRefs.current.set(group.nodeId, element); else nodeButtonRefs.current.delete(group.nodeId) }}
type="button" onClick={event => onOpenNode(group.node, event.currentTarget)} aria-label={`Open node ${name}`}>{name}
: {name} }
- {group.node ? : Unknown }
+
+ {group.node ? : Unknown }
+ {group.nodeId && onViewLogs(group.nodeId, model.model_name)}> Logs }
+
{group.replicas.length} replica{group.replicas.length === 1 ? '' : 's'} · {inFlight} in flight · {lastUsed ? timeAgo(lastUsed) : 'never used'}
- {group.replicas.map(replica => Replica {Number.isFinite(replica.replica_index) ? replica.replica_index + 1 : '—'} {replica.address || 'No address'} )}
+ {group.replicas.map(replica => {
+ const replicaNumber = Number.isFinite(replica.replica_index) ? replica.replica_index + 1 : null
+ const processKey = `${model.model_name}#${replica.replica_index ?? 0}`
+ return
+ Replica {replicaNumber ?? '—'}
+ {replica.address || 'No address'}
+ {group.nodeId && onViewLogs(group.nodeId, processKey)}>View logs }
+
+ })}
)
})}
diff --git a/core/http/react-ui/src/components/nodes/NodeFleetTable.jsx b/core/http/react-ui/src/components/nodes/NodeFleetTable.jsx
index e37eec4df..fdec881b7 100644
--- a/core/http/react-ui/src/components/nodes/NodeFleetTable.jsx
+++ b/core/http/react-ui/src/components/nodes/NodeFleetTable.jsx
@@ -47,7 +47,7 @@ export default function NodeFleetTable({ nodes, selectedIds, onSelectionChange,
- 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)} />
@@ -64,7 +64,7 @@ export default function NodeFleetTable({ nodes, selectedIds, onSelectionChange,
const rows = group.nodes.map(node => (
onInspect(node, event.currentTarget)}
onKeyDown={event => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onInspect(node, event.currentTarget) } }}>
- event.stopPropagation()}> event.stopPropagation()}> setMany([node.id], event.target.checked)} />
{ event.stopPropagation(); onInspect(node, event.currentTarget) }}>{node.name} {node.node_type || 'backend'} · {node.address || 'No address'}
{nodeLifecycleAction(node.status) === 'approve' && { event.stopPropagation(); onApprove(node.id) }}>Approve }
@@ -77,7 +77,7 @@ export default function NodeFleetTable({ nodes, selectedIds, onSelectionChange,
if (groupBy === 'none') return rows
return [
- 0 && groupSelected === groupIds.length}
+ 0 && groupSelected === groupIds.length}
ref={input => { if (input) input.indeterminate = groupSelected > 0 && groupSelected < groupIds.length }}
onChange={event => setMany(groupIds, event.target.checked)} /> {group.label}{group.nodes.length} nodes · {groupSelected} selected
,
diff --git a/core/http/react-ui/src/pages/NodeDetail.jsx b/core/http/react-ui/src/pages/NodeDetail.jsx
index 681ef9d80..0796de103 100644
--- a/core/http/react-ui/src/pages/NodeDetail.jsx
+++ b/core/http/react-ui/src/pages/NodeDetail.jsx
@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
-import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
+import { Link, useParams, useNavigate, useOutletContext } from 'react-router-dom'
import { nodesApi } from '../utils/api'
import PageHeader from '../components/PageHeader'
import LoadingSpinner from '../components/LoadingSpinner'
import ConfirmDialog from '../components/ConfirmDialog'
+import ActionMenu from '../components/ActionMenu'
import StatusPill from '../components/nodes/StatusPill'
import CapacityEditor from '../components/nodes/CapacityEditor'
import KeyValueChips from '../components/nodes/KeyValueChips'
@@ -22,6 +23,7 @@ export default function NodeDetail() {
const [models, setModels] = useState([])
const [backends, setBackends] = useState([])
const [loading, setLoading] = useState(true)
+ const [loadError, setLoadError] = useState('')
const [confirmRemove, setConfirmRemove] = useState(false)
const [confirmUnload, setConfirmUnload] = useState(null)
const [confirmDeleteBackend, setConfirmDeleteBackend] = useState(null)
@@ -30,13 +32,17 @@ export default function NodeDetail() {
const [confirmShrinkState, setConfirmShrinkState] = useState(null)
const refresh = useCallback(async () => {
+ setLoading(true)
+ setLoadError('')
try {
const n = await nodesApi.get(id)
- setNode(n)
const [m, b] = await Promise.all([nodesApi.getModels(id), nodesApi.getBackends(id)])
+ setNode(n)
setModels(Array.isArray(m) ? m : [])
setBackends(Array.isArray(b) ? b : [])
} catch (err) {
+ setNode(null)
+ setLoadError(err.message || 'Unable to load node')
addToast(`Failed to load node: ${err.message}`, 'error')
} finally {
setLoading(false)
@@ -50,6 +56,14 @@ export default function NodeDetail() {
}), [])
if (loading) return
+ if (!node && loadError) {
+ const notFound = loadError.includes('404') || loadError.toLowerCase().includes('not found')
+ return
+
Nodes}
+ title={notFound ? 'Node not found' : 'Could not load this node'} supporting={notFound ? 'It may have been removed from the cluster.' : loadError} />
+ {!notFound && Could not load this node The cluster may be temporarily unavailable.
void refresh()}>Retry }
+
+ }
if (!node) return
const drain = async () => { try { await nodesApi.drain(id); addToast('Node set to draining', 'success'); refresh() } catch (e) { addToast(e.message, 'error') } }
@@ -78,23 +92,25 @@ export default function NodeDetail() {
})()
return (
-
+
navigate('/app/nodes')} className="link-plain"> Cluster}
- title={<> {node.name}>}
- supporting={node.address}
+ className="nodes-fleet-page__header node-detail__header"
+ eyebrow={ Nodes{node.name} }
+ title={node.name}
+ supporting={{node.address || node.id} {node.node_type || 'backend'} node }
actions={
<>
{lifecycleAction === 'approve' && Approve }
{lifecycleAction === 'resume' && Resume }
{lifecycleAction === 'drain' && Drain }
- setConfirmRemove(true)}> Remove
+ setConfirmRemove(true),
+ }]} />
>
}
/>
- {/* Inline resource and activity metrics - no boxes, just labelled values. */}
-
+
VRAM
{vram ? `${formatVRAM(vram.used) || '0'} / ${formatVRAM(vram.total)}` : 'No data'}
@@ -122,189 +138,75 @@ export default function NodeDetail() {
In-flight
{node.in_flight_count || 0}
- {node.node_type !== 'agent' && (
-
-
Capacity
-
refresh()}
- />
-
- )}
-
-
- {/* Running models */}
-
-
Running models
- {models.length === 0 ? (
-
-
- No models loaded yet - they'll appear here when scheduled to this node.
-
- ) : (
-
- )}
-
-
- {/* Installed backends */}
-
-
-
Installed backends
-
navigate(`/app/backends?target=${encodeURIComponent(id)}`)}
- title={`Install a backend on ${node.name}`}
- >
- Add backend
-
+
+
Heartbeat
+
{timeAgo(node.last_heartbeat)}
- {backends.length === 0 ? (
-
- None installed. { e.preventDefault(); navigate(`/app/backends?target=${encodeURIComponent(id)}`) }}>Install one from the gallery to schedule models here.
-
- ) : (
-
-
-
- Name
- Type
- Installed At
- Actions
-
-
-
- {backends.map(b => (
-
-
- {b.name}
-
-
-
- {b.is_system ? 'system' : 'gallery'}
-
-
-
- {b.installed_at ? timeAgo(b.installed_at) : '-'}
-
-
- {!b.is_system && (
-
- upgradeBackend(b.name)}
- title="Upgrade backend on this node"
- >
-
-
- setConfirmDeleteBackend({ backend: b.name })}
- title="Delete backend from this node"
- >
-
-
-
- )}
-
-
- ))}
-
-
- )}
-
+
- {/* Labels - node.replica-slots is filtered out so the Capacity editor
- stays the single source of truth for that label. */}
-
-
Labels
-
k !== 'node.replica-slots'))}
- onAdd={addLabel}
- onRemove={delLabel}
- placeholderKey="key"
- placeholderValue="value"
- ariaLabel="Node labels"
- />
+
+
+
+ Running models Replica processes scheduled to this node
{models.length} replica{models.length === 1 ? '' : 's'}
+ {models.length === 0 ? (
+ No models loaded yet. Replicas will appear here when scheduled to this node.
+ ) : (
+
+
+ Model State In flight Actions
+ {(() => {
+ const replicaCounts = {}
+ models.forEach(model => { replicaCounts[model.model_name] = (replicaCounts[model.model_name] || 0) + 1 })
+ return models.map(model => {
+ const stCfg = modelStateConfig[model.state] || modelStateConfig.idle
+ const replicaNumber = (model.replica_index ?? 0) + 1
+ const showReplica = replicaCounts[model.model_name] > 1
+ const processKey = `${model.model_name}#${model.replica_index ?? 0}`
+ return
+ {model.model_name}{showReplica && rep {replicaNumber} }{model.state} · {model.in_flight ?? 0} in flight
+ {model.state}
+ {model.in_flight ?? 0}
+ navigate(`/app/node-backend-logs/${encodeURIComponent(id)}/${encodeURIComponent(processKey)}`) },
+ { divider: true },
+ { key: 'unload', icon: 'fa-stop', label: 'Unload model…', danger: true, onClick: () => setConfirmUnload({ modelName: model.model_name, inFlight: model.in_flight ?? 0 }) },
+ ]} />
+
+ })
+ })()}
+
+
+ )}
+
+
+
+ Installed backends Runtime engines available on this node
navigate(`/app/backends?target=${encodeURIComponent(id)}`)}> Add backend
+ {backends.length === 0 ? (
+ None installed. navigate(`/app/backends?target=${encodeURIComponent(id)}`)}>Install one from the gallery
+ ) : (
+
+
+ Name Source Installed Actions
+ {backends.map(backend =>
+ {backend.name}
+ {backend.is_system ? 'system' : 'gallery'}
+ {backend.installed_at ? timeAgo(backend.installed_at) : '—'}
+ {!backend.is_system && upgradeBackend(backend.name) },
+ { divider: true },
+ { key: 'delete', icon: 'fa-trash', label: 'Delete backend…', danger: true, onClick: () => setConfirmDeleteBackend({ backend: backend.name }) },
+ ]} />}
+ )}
+
+
+ )}
+
+
+
+
{
+ navigate(`/app/node-backend-logs/${encodeURIComponent(nodeId)}/${encodeURIComponent(processKey)}`)
+ }
+
+ const openModelLogs = (model, invoker) => {
+ const replicas = Array.isArray(model.replicas) ? model.replicas : []
+ if (replicas.length === 1 && replicas[0].node_id) {
+ openReplicaLogs(replicas[0].node_id, `${model.model_name}#${replicas[0].replica_index ?? 0}`)
+ return
+ }
+ openModelInspector(model, invoker)
+ }
+
const closeModelDrilldown = () => {
setInspectedModelName(null)
setModelDrillNodeId(null)
@@ -367,7 +381,7 @@ export default function Nodes() {
{modelLoadState === 'loaded' && groupedModels.length > 0 && <>
setModelQuery(event.target.value)} />
+ onViewLogs={openModelLogs} onStop={promptStopModel} stoppingName={stoppingModelName} sort={modelSort} onSortChange={setModelSort} />
Page {modelPagination.page} of {modelPagination.totalPages} setModelPage(value => value - 1)}>Previous setModelPage(value => value + 1)}>Next
>}
@@ -377,7 +391,7 @@ export default function Nodes() {
onApprove={id => actOnNode('approve', id, 'Node approved')}
onDrain={id => actOnNode('drain', id, 'Node set to draining')} onResume={id => actOnNode('resume', id, 'Node resumed')} />
}
- {workbenchView === 'models' && !drilledNode &&
}
+ {workbenchView === 'models' && !drilledNode &&
}
{workbenchView === 'models' && drilledNode &&
actOnNode('approve', id, 'Node approved')}
diff --git a/docs/content/features/distributed-mode.md b/docs/content/features/distributed-mode.md
index 4c8ad84d7..f8a06539a 100644
--- a/docs/content/features/distributed-mode.md
+++ b/docs/content/features/distributed-mode.md
@@ -489,6 +489,12 @@ Workers can run **multiple models concurrently** - each model gets its own gRPC
When the SmartRouter needs to free capacity, it can unload models with zero in-flight requests without affecting other models on the same worker.
+### Managing nodes in the WebUI
+
+Open **Operate → Nodes** to inspect fleet health, filter or select workers, and view running models across the cluster. The **Running models** view groups replicas by model. Its **View logs…** action opens logs directly when there is one placement; when a model has several placements, it opens the model inspector so you can choose all logs for one node or the logs for one replica.
+
+Open a node's full details for node-scoped work: viewing replica logs, unloading a model, managing installed backends, changing replica capacity, or editing scheduling labels. Diagnostic actions are listed before destructive actions in row menus.
+
## Node Management API
The API is split into two prefixes with distinct auth: