fix(ui): restore node operation controls (#12068)

The node restructure hid backend logs and split related controls
across inconsistent layouts.

Restore contextual log actions and align the detail page with the
fleet dashboard. Make multi-node selection clear and accessible.

Assisted-by: Codex:gpt-5 Playwright ESLint

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
localai-org-maint-botandEttore Di Giacinto authored and GitHub committed 2026-09-15 15:24:59 +02:00
1 parent 5ce83ceb46
commit d5256a5584
9 files changed
+307 -208

No files matched your search

+43 -3
View File
@@ -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)
})
})
@@ -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)
+84 -4
View File
@@ -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; }
@@ -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 (
<div className="fleet-table-wrap model-fleet-table-wrap">
<table className="fleet-table model-fleet-table" aria-label="Running models">
@@ -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…',
@@ -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
? <button ref={element => { 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}</button>
: <strong>{name}</strong>}
{group.node ? <StatusPill status={group.node.status} /> : <span className="status-pill status-pill--neutral">Unknown</span>}
<div className="model-inspector__node-actions">
{group.node ? <StatusPill status={group.node.status} /> : <span className="status-pill status-pill--neutral">Unknown</span>}
{group.nodeId && <button type="button" className="model-inspector__logs" aria-label={`View all ${model.model_name} logs on ${name}`}
onClick={() => onViewLogs(group.nodeId, model.model_name)}><i className="fas fa-terminal" aria-hidden="true" /> Logs</button>}
</div>
</div>
<p>{group.replicas.length} replica{group.replicas.length === 1 ? '' : 's'} · {inFlight} in flight · {lastUsed ? timeAgo(lastUsed) : 'never used'}</p>
<ul>{group.replicas.map(replica => <li key={replica.id || `${replica.replica_index}:${replica.address}`}><span>Replica {Number.isFinite(replica.replica_index) ? replica.replica_index + 1 : '—'}</span><code>{replica.address || 'No address'}</code></li>)}</ul>
<ul>{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 <li key={replica.id || `${replica.replica_index}:${replica.address}`}>
<span>Replica {replicaNumber ?? '—'}</span>
<code>{replica.address || 'No address'}</code>
{group.nodeId && <button type="button" className="model-inspector__replica-logs"
aria-label={`View logs for ${model.model_name} replica ${replicaNumber ?? 'unknown'} on ${name}`}
onClick={() => onViewLogs(group.nodeId, processKey)}>View logs</button>}
</li>
})}</ul>
</article>
)
})}</div>
@@ -47,7 +47,7 @@ export default function NodeFleetTable({ nodes, selectedIds, onSelectionChange,
<table className="fleet-table" aria-label="Fleet nodes">
<thead>
<tr>
<th className="fleet-table__check"><input type="checkbox" aria-label="Select visible nodes" checked={visibleIds.length > 0 && selectedVisible === visibleIds.length}
<th className="fleet-table__check"><input className="fleet-checkbox" type="checkbox" aria-label="Select page" title="Select nodes on this page" checked={visibleIds.length > 0 && selectedVisible === visibleIds.length}
ref={input => { if (input) input.indeterminate = selectedVisible > 0 && selectedVisible < visibleIds.length }}
onChange={event => setMany(visibleIds, event.target.checked)} /></th>
<th><SortButton column="name" label="Node" sort={sort} onSortChange={onSortChange} /></th>
@@ -64,7 +64,7 @@ export default function NodeFleetTable({ nodes, selectedIds, onSelectionChange,
const rows = group.nodes.map(node => (
<tr key={node.id} className={`fleet-table__row${selectedIds.has(node.id) ? ' is-selected' : ''}`} tabIndex="0" onClick={event => onInspect(node, event.currentTarget)}
onKeyDown={event => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onInspect(node, event.currentTarget) } }}>
<td onClick={event => event.stopPropagation()}><input type="checkbox" aria-label={`Select ${node.name}`} checked={selectedIds.has(node.id)}
<td className="fleet-table__check" onClick={event => event.stopPropagation()}><input className="fleet-checkbox" type="checkbox" aria-label={`Select ${node.name}`} checked={selectedIds.has(node.id)}
onChange={event => setMany([node.id], event.target.checked)} /></td>
<td><button type="button" className="fleet-table__node" aria-label={`Inspect ${node.name}`} onClick={event => { event.stopPropagation(); onInspect(node, event.currentTarget) }}>{node.name}</button><span>{node.node_type || 'backend'} · {node.address || 'No address'}</span></td>
<td><StatusPill status={node.status} />{nodeLifecycleAction(node.status) === 'approve' && <button type="button" className="fleet-table__approve" aria-label={`Approve ${node.name}`} onClick={event => { event.stopPropagation(); onApprove(node.id) }}>Approve</button>}</td>
@@ -77,7 +77,7 @@ export default function NodeFleetTable({ nodes, selectedIds, onSelectionChange,
if (groupBy === 'none') return rows
return [
<tr key={`group:${group.key}`} className="fleet-table__group">
<th colSpan="7"><label><input type="checkbox" aria-label={`Select ${group.label} group`} checked={groupIds.length > 0 && groupSelected === groupIds.length}
<th colSpan="7"><label><input className="fleet-checkbox" type="checkbox" aria-label={`Select ${group.label} group`} checked={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}</label><span>{group.nodes.length} nodes · {groupSelected} selected</span></th>
</tr>,
+92 -190
View File
@@ -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 <div className="page page--wide loading-center"><LoadingSpinner size="lg" /></div>
if (!node && loadError) {
const notFound = loadError.includes('404') || loadError.toLowerCase().includes('not found')
return <div className="page page--wide nodes-fleet-page node-detail-page">
<PageHeader className="nodes-fleet-page__header" eyebrow={<Link to="/app/nodes" className="link-plain"><i className="fas fa-arrow-left icon-before" aria-hidden="true" />Nodes</Link>}
title={notFound ? 'Node not found' : 'Could not load this node'} supporting={notFound ? 'It may have been removed from the cluster.' : loadError} />
{!notFound && <div className="node-detail__load-error" role="alert"><i className="fas fa-triangle-exclamation" aria-hidden="true" /><div><strong>Could not load this node</strong><span>The cluster may be temporarily unavailable.</span></div><button type="button" className="btn btn-secondary btn-sm" onClick={() => void refresh()}>Retry</button></div>}
</div>
}
if (!node) return <div className="page page--wide"><PageHeader title="Node not found" /></div>
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 (
<div className="page page--wide">
<div className="page page--wide nodes-fleet-page node-detail-page">
<PageHeader
eyebrow={<a onClick={() => navigate('/app/nodes')} className="link-plain"><i className="fas fa-arrow-left icon-before" aria-hidden="true" />Cluster</a>}
title={<><StatusPill status={node.status} /> {node.name}</>}
supporting={node.address}
className="nodes-fleet-page__header node-detail__header"
eyebrow={<span className="node-detail__breadcrumb"><Link to="/app/nodes">Nodes</Link><i className="fas fa-chevron-right" aria-hidden="true" /><span>{node.name}</span></span>}
title={node.name}
supporting={<span className="node-detail__identity"><StatusPill status={node.status} /><span className="cell-mono">{node.address || node.id}</span><span>{node.node_type || 'backend'} node</span></span>}
actions={
<>
{lifecycleAction === 'approve' && <button className="btn btn-primary btn-sm" onClick={approve}><i className="fas fa-check" /> Approve</button>}
{lifecycleAction === 'resume' && <button className="btn btn-secondary btn-sm" onClick={resume}><i className="fas fa-play" /> Resume</button>}
{lifecycleAction === 'drain' && <button className="btn btn-secondary btn-sm" onClick={drain}><i className="fas fa-pause" /> Drain</button>}
<button className="btn btn-danger btn-sm" onClick={() => setConfirmRemove(true)}><i className="fas fa-trash" /> Remove</button>
<ActionMenu ariaLabel={`${node.name} actions`} triggerLabel={`Actions for ${node.name}`} items={[{
key: 'remove', icon: 'fa-trash', label: 'Remove node…', danger: true, onClick: () => setConfirmRemove(true),
}]} />
</>
}
/>
{/* Inline resource and activity metrics - no boxes, just labelled values. */}
<div className="node-detail__metrics">
<section className="node-detail__metrics" aria-label="Node resources">
<div>
<div className="drawer-eyebrow">VRAM</div>
<span className="cell-mono">{vram ? `${formatVRAM(vram.used) || '0'} / ${formatVRAM(vram.total)}` : 'No data'}</span>
@@ -122,189 +138,75 @@ export default function NodeDetail() {
<div className="drawer-eyebrow">In-flight</div>
<span className="cell-mono">{node.in_flight_count || 0}</span>
</div>
{node.node_type !== 'agent' && (
<div className="flex-1-min">
<div className="drawer-eyebrow">Capacity</div>
<CapacityEditor
node={node}
loadedModelCounts={loadedModelCounts}
confirmShrink={confirmShrink}
addToast={addToast}
onUpdate={() => refresh()}
/>
</div>
)}
</div>
{/* Running models */}
<div className="mt-lg">
<div className="drawer-eyebrow">Running models</div>
{models.length === 0 ? (
<p className="text-note m-0 mb-md">
<i className="fas fa-cube icon-before op-60" aria-hidden="true" />
No models loaded yet - they'll appear here when scheduled to this node.
</p>
) : (
<table className="table table--flush">
<thead>
<tr>
<th>Model</th>
<th>State</th>
<th>In-Flight</th>
<th className="col-w-40">Logs</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{(() => {
// Pre-compute per-model replica counts so the disambiguation
// pill only renders when this node actually hosts >1 replica
// of the same model. Single-replica deployments stay clean.
const replicaCounts = {}
models.forEach(m => { replicaCounts[m.model_name] = (replicaCounts[m.model_name] || 0) + 1 })
return models.map(m => {
const stCfg = modelStateConfig[m.state] || modelStateConfig.idle
const showReplica = (replicaCounts[m.model_name] || 0) > 1
// Per-replica process key - what the worker stores logs under and what the
// store's GetLines/Subscribe match on for replica-scoped filtering.
const processKey = `${m.model_name}#${m.replica_index ?? 0}`
return (
<tr key={m.id || `${m.model_name}#${m.replica_index ?? 0}`}>
<td className="cell-mono">
{m.model_name}
{showReplica && (
<span
aria-label={`replica ${m.replica_index ?? 0}`}
title={`Replica ${m.replica_index ?? 0} on this node`}
className="inline-tag"
>
rep {m.replica_index ?? 0}
</span>
)}
</td>
<td>
<span
className="state-pill"
style={{ background: stCfg.bg, color: stCfg.color, border: `1px solid ${stCfg.border}` }}
>
{m.state}
</span>
</td>
<td className="cell-mono">
{m.in_flight ?? 0}
</td>
<td>
<a
href="#"
onClick={(e) => {
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'}
>
<i className="fas fa-terminal" />
</a>
</td>
<td className="text-right">
<button
className="btn btn-danger btn-sm"
title={m.in_flight > 0 ? 'Unload model (has in-flight requests)' : 'Unload model'}
onClick={() => setConfirmUnload({ modelName: m.model_name, inFlight: m.in_flight ?? 0 })}
>
<i className="fas fa-stop" />
</button>
</td>
</tr>
)
})
})()}
</tbody>
</table>
)}
</div>
{/* Installed backends */}
<div className="mt-lg">
<div className="hstack hstack--between mb-sm">
<div className="drawer-eyebrow m-0">Installed backends</div>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/app/backends?target=${encodeURIComponent(id)}`)}
title={`Install a backend on ${node.name}`}
>
<i className="fas fa-plus" /> Add backend
</button>
<div>
<div className="drawer-eyebrow">Heartbeat</div>
<span>{timeAgo(node.last_heartbeat)}</span>
</div>
{backends.length === 0 ? (
<p className="text-note m-0">
None installed. <a href="#" className="text-primary" onClick={(e) => { e.preventDefault(); navigate(`/app/backends?target=${encodeURIComponent(id)}`) }}>Install one from the gallery</a> to schedule models here.
</p>
) : (
<table className="table table--flush">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Installed At</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{backends.map(b => (
<tr key={b.name}>
<td className="cell-mono">
{b.name}
</td>
<td>
<span className={`state-pill ${b.is_system ? 'state-pill--system' : 'state-pill--gallery'}`}>
{b.is_system ? 'system' : 'gallery'}
</span>
</td>
<td className="text-note">
{b.installed_at ? timeAgo(b.installed_at) : '-'}
</td>
<td className="text-right">
{!b.is_system && (
<div className="inline-flex gap-xs">
<button
className="btn btn-secondary btn-sm"
onClick={() => upgradeBackend(b.name)}
title="Upgrade backend on this node"
>
<i className="fas fa-arrow-up" />
</button>
<button
className="btn btn-danger-ghost btn-sm"
onClick={() => setConfirmDeleteBackend({ backend: b.name })}
title="Delete backend from this node"
>
<i className="fas fa-trash" />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
{/* Labels - node.replica-slots is filtered out so the Capacity editor
stays the single source of truth for that label. */}
<div className="mt-lg">
<div className="drawer-eyebrow">Labels</div>
<KeyValueChips
pairs={Object.fromEntries(Object.entries(node.labels || {}).filter(([k]) => k !== 'node.replica-slots'))}
onAdd={addLabel}
onRemove={delLabel}
placeholderKey="key"
placeholderValue="value"
ariaLabel="Node labels"
/>
<div className="node-detail__layout">
<div className="node-detail__workloads">
<section className="fleet-workbench node-detail__workbench" aria-label="Running models">
<div className="model-workbench__scope"><div><strong>Running models</strong><span>Replica processes scheduled to this node</span></div><span>{models.length} replica{models.length === 1 ? '' : 's'}</span></div>
{models.length === 0 ? (
<div className="node-detail__empty"><i className="fas fa-cube" aria-hidden="true" /><span>No models loaded yet. Replicas will appear here when scheduled to this node.</span></div>
) : (
<div className="fleet-table-wrap">
<table className="fleet-table node-detail__table node-detail__table--models">
<thead><tr><th>Model</th><th>State</th><th>In flight</th><th className="model-fleet-table__actions"><span className="sr-only">Actions</span></th></tr></thead>
<tbody>{(() => {
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 <tr key={model.id || processKey}>
<td className="cell-mono">{model.model_name}{showReplica && <span aria-label={`replica ${replicaNumber}`} title={`Replica ${replicaNumber} on this node`} className="inline-tag">rep {replicaNumber}</span>}<span className="node-detail__mobile-meta">{model.state} · {model.in_flight ?? 0} in flight</span></td>
<td><span className="state-pill" style={{ background: stCfg.bg, color: stCfg.color, border: `1px solid ${stCfg.border}` }}>{model.state}</span></td>
<td className="cell-mono">{model.in_flight ?? 0}</td>
<td className="model-fleet-table__actions"><ActionMenu compact ariaLabel={`${model.model_name} replica ${replicaNumber} actions`} triggerLabel={`Actions for ${model.model_name} replica ${replicaNumber}`} items={[
{ key: 'logs', icon: 'fa-terminal', label: 'View logs', onClick: () => 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 }) },
]} /></td>
</tr>
})
})()}</tbody>
</table>
</div>
)}
</section>
<section className="fleet-workbench node-detail__workbench" aria-label="Installed backends">
<div className="model-workbench__scope"><div><strong>Installed backends</strong><span>Runtime engines available on this node</span></div><button type="button" className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/backends?target=${encodeURIComponent(id)}`)}><i className="fas fa-plus" aria-hidden="true" /> Add backend</button></div>
{backends.length === 0 ? (
<div className="node-detail__empty"><span>None installed.</span><button type="button" className="node-detail__text-action" onClick={() => navigate(`/app/backends?target=${encodeURIComponent(id)}`)}>Install one from the gallery</button></div>
) : (
<div className="fleet-table-wrap">
<table className="fleet-table node-detail__table node-detail__table--backends">
<thead><tr><th>Name</th><th>Source</th><th>Installed</th><th className="model-fleet-table__actions"><span className="sr-only">Actions</span></th></tr></thead>
<tbody>{backends.map(backend => <tr key={backend.name}>
<td className="cell-mono">{backend.name}</td>
<td><span className={`state-pill ${backend.is_system ? 'state-pill--system' : 'state-pill--gallery'}`}>{backend.is_system ? 'system' : 'gallery'}</span></td>
<td className="text-note">{backend.installed_at ? timeAgo(backend.installed_at) : '—'}</td>
<td className="model-fleet-table__actions">{!backend.is_system && <ActionMenu compact ariaLabel={`${backend.name} backend actions`} triggerLabel={`Actions for backend ${backend.name}`} items={[
{ key: 'upgrade', icon: 'fa-arrow-up', label: 'Upgrade backend', onClick: () => upgradeBackend(backend.name) },
{ divider: true },
{ key: 'delete', icon: 'fa-trash', label: 'Delete backend…', danger: true, onClick: () => setConfirmDeleteBackend({ backend: backend.name }) },
]} />}</td>
</tr>)}</tbody>
</table>
</div>
)}
</section>
</div>
<aside className="node-detail__configuration" aria-label="Node configuration">
{node.node_type !== 'agent' && <section><div className="fleet-kicker">Replica capacity</div><p>Limit how many replicas of one model may run here.</p><CapacityEditor node={node} loadedModelCounts={loadedModelCounts} confirmShrink={confirmShrink} addToast={addToast} onUpdate={() => refresh()} /></section>}
<section><div className="fleet-kicker">Labels</div><p>Labels control scheduling and describe this worker.</p><KeyValueChips pairs={Object.fromEntries(Object.entries(node.labels || {}).filter(([key]) => key !== 'node.replica-slots'))} onAdd={addLabel} onRemove={delLabel} placeholderKey="key" placeholderValue="value" ariaLabel="Node labels" /></section>
</aside>
</div>
<ConfirmDialog
+17 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { backendControlApi, nodesApi } from '../utils/api'
import { filterModels, filterNodes, groupModels, paginateModels, paginateNodes, runBounded, sortModels, sortNodes, summarizeFleet } from '../utils/nodeFleet'
@@ -75,6 +75,7 @@ function FleetSelect({ label, value, onChange, children }) {
}
export default function Nodes() {
const navigate = useNavigate()
const { addToast } = useOutletContext()
const { t } = useTranslation('admin')
const [nodes, setNodes] = useState([])
@@ -260,6 +261,19 @@ export default function Nodes() {
setReturnFocusNodeId(null)
}
const openReplicaLogs = (nodeId, processKey) => {
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 && <>
<div className="model-toolbar"><input className="input fleet-toolbar__search" type="search" aria-label="Search running models" placeholder="Search model or backend…" value={modelQuery} onChange={event => setModelQuery(event.target.value)} /></div>
<ModelFleetTable models={modelPagination.items} selectedName={inspectedModelName} inspectorOpen={!!inspectedModel && !drilledNode} onInspect={openModelInspector}
onStop={promptStopModel} stoppingName={stoppingModelName} sort={modelSort} onSortChange={setModelSort} />
onViewLogs={openModelLogs} onStop={promptStopModel} stoppingName={stoppingModelName} sort={modelSort} onSortChange={setModelSort} />
<div className="fleet-pagination"><span>Page {modelPagination.page} of {modelPagination.totalPages}</span><button type="button" className="btn btn-secondary btn-sm" aria-label="Previous model page" disabled={modelPagination.page === 1} onClick={() => setModelPage(value => value - 1)}>Previous</button><button type="button" className="btn btn-secondary btn-sm" aria-label="Next model page" disabled={modelPagination.page === modelPagination.totalPages} onClick={() => setModelPage(value => value + 1)}>Next</button></div>
</>}
</div>
@@ -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 && <ModelInspector model={inspectedModel} nodes={nodes} open={!!inspectedModel} onClose={closeModelDrilldown} onOpenNode={openModelNode} focusNodeId={returnFocusNodeId} />}
{workbenchView === 'models' && !drilledNode && <ModelInspector model={inspectedModel} nodes={nodes} open={!!inspectedModel} onClose={closeModelDrilldown} onOpenNode={openModelNode} onViewLogs={openReplicaLogs} focusNodeId={returnFocusNodeId} />}
{workbenchView === 'models' && drilledNode && <NodeInspector node={drilledNode} open onClose={closeModelDrilldown}
onBack={returnToModel} backLabel={`Back to ${inspectedModel?.model_name || 'model'}`}
onApprove={id => actOnNode('approve', id, 'Node approved')}
@@ -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: