Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions core/http/react-ui/e2e/backend-logs.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,26 @@ test.describe('Backend Logs', () => {
// Button label changes to "Show details"
await expect(page.locator('button', { hasText: 'Show details' })).toBeVisible()
})

test('distributed resolver displays zero-based replica indexes as one-based labels', async ({ page }) => {
await page.route('**/api/nodes', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 'worker-1', name: 'Worker 1' }]),
}))
await page.route('**/api/nodes/worker-1/models', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ model_name: 'llama', replica_index: 0, state: 'loaded' },
{ model_name: 'llama', replica_index: 1, state: 'loaded' },
]),
}))

await page.goto('/app/backend-logs/llama')

await expect(page.getByText('worker-1 · replica 1 · loaded')).toBeVisible()
await expect(page.getByText('worker-1 · replica 2 · loaded')).toBeVisible()
await expect(page.locator('a[href="/app/node-backend-logs/worker-1/llama"]')).toHaveCount(2)
})
})
2 changes: 2 additions & 0 deletions core/http/react-ui/e2e/nodes-detail.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ test.describe('Node detail page', () => {
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$/)
await page.getByRole('link', { name: 'Back to alpha' }).click()
await expect(page).toHaveURL(/\/app\/nodes\/n1$/)
})

test('is reachable by clicking a roster panel', async ({ page }) => {
Expand Down
46 changes: 44 additions & 2 deletions core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ test.describe('Nodes fleet dashboard', () => {
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 expect(page.getByText('3 visible, 9 outside this view')).toBeVisible()
await page.getByRole('button', { name: 'Drain selected' }).evaluate(button => {
button.click()
button.click()
Expand All @@ -150,6 +151,45 @@ test.describe('Nodes fleet dashboard', () => {
expect(resumeRequests).not.toContain('n9')
})

test('opens Add worker as an accessible setup drawer and restores focus', async ({ page }) => {
await mockNodes(page, [baseNodes[0]])
await page.goto('/app/nodes')

const trigger = page.getByRole('button', { name: 'Add worker' })
await trigger.click()
const drawer = page.getByRole('dialog', { name: 'Add worker' })
await expect(drawer).toBeVisible()
await expect(drawer.getByRole('radiogroup', { name: 'Worker type' })).toBeVisible()
await expect(drawer.getByText('CLI', { exact: true })).toBeVisible()
await expect(drawer.getByText('Docker', { exact: true })).toBeVisible()
await expect(drawer.getByRole('button', { name: 'Copy command' })).toHaveCount(2)
await expect(drawer.getByRole('link', { name: 'Distributed mode documentation' })).toBeVisible()
await expect(drawer.getByRole('button', { name: 'Close worker setup' })).toBeFocused()

await page.keyboard.press('Escape')
await expect(drawer).toBeHidden()
await expect(trigger).toBeFocused()
})

test('presents worker setup as a modal drawer on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockNodes(page, [baseNodes[0]])
await page.goto('/app/nodes')
await page.getByRole('button', { name: 'Add worker' }).click()
await expect(page.getByRole('dialog', { name: 'Add worker' })).toHaveAttribute('aria-modal', 'true')
})

test('opens running-model logs with a fleet return destination', async ({ page }) => {
await mockNodes(page)
await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([baseModels[3]]) }))
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 page.getByRole('link', { name: 'Back to nodes' }).click()
await expect(page).toHaveURL(/\/app\/nodes$/)
})

test('disables bulk controls and remove confirmation while removal is running', async ({ page }) => {
await mockNodes(page, [baseNodes[0]])
let finishRemove
Expand Down Expand Up @@ -504,10 +544,12 @@ test.describe('Nodes fleet dashboard', () => {
await expect(modelControl).toHaveAttribute('aria-expanded', 'false')
await expect(modelControl).not.toHaveAttribute('aria-controls')

await page.getByRole('button', { name: 'Inspect Whisper large v3' }).click()
const whisperControl = page.getByRole('button', { name: 'Inspect Whisper large v3' })
await whisperControl.click()
await expect(page.getByRole('complementary', { name: 'Model inspector' })).toContainText('missing')
await expect(page.getByRole('complementary', { name: 'Model inspector' })).toContainText('Unknown')
await page.getByRole('button', { name: 'Close model inspector' }).click()
await expect(whisperControl).toBeFocused()

await modelsTab.focus()
await modelsTab.press('ArrowLeft')
Expand Down Expand Up @@ -570,7 +612,7 @@ test.describe('Nodes fleet dashboard', () => {
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await expect(inspector).toBeVisible()

await page.getByRole('button', { name: 'Remove selected' }).click()
await page.getByRole('button', { name: 'Remove selected' }).evaluate(button => button.click())
await expect(page.getByRole('alertdialog')).toBeVisible()
await page.keyboard.press('Escape')

Expand Down
41 changes: 37 additions & 4 deletions core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ async function openNodeDetail(page) {
}

test.describe('Nodes page — per-node backend actions', () => {
test('backend log page uses one-based replica labels, token classes, and a safe direct-link return', async ({ page }) => {
await mockDistributedNodes(page)
await page.route(`**/api/nodes/${NODE_ID}/models`, route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ model_name: 'llama', replica_index: 0 },
{ model_name: 'llama', replica_index: 1 },
]),
}))
await page.route(`**/api/nodes/${NODE_ID}/backend-logs/**`, route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
{ timestamp: '2026-09-15T12:00:00Z', stream: 'stdout', text: 'ready' },
]) }))

await page.goto(`/app/node-backend-logs/${NODE_ID}/llama%230`)
await expect(page.getByText('Replica 1', { exact: true }).first()).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('radio', { name: 'Replica 2' })).toBeVisible()
await expect(page.locator('.node-backend-logs__toolbar')).toBeVisible()
await expect(page.locator('.node-backend-logs__output')).toBeVisible()
await expect(page.getByText('ready', { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Clear' }).click()
await expect(page.getByText('ready', { exact: true })).toBeHidden()
await expect(page.locator('.node-backend-logs [style]')).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Back to nodes' })).toHaveAttribute('href', '/app/nodes')

await page.getByRole('radio', { name: 'Replica 2' }).click()
await expect(page).toHaveURL(/llama%231$/)
})

test('upgrade affordance is self-explanatory (not "Reinstall backend" with a sync icon)', async ({ page }) => {
await mockDistributedNodes(page)
await openNodeDetail(page)
Expand All @@ -112,7 +141,8 @@ test.describe('Nodes page — per-node backend actions', () => {
// Positive: a self-explanatory upgrade affordance is rendered next to the
// backend row. We accept either an arrow-up or arrows-rotate glyph; both
// map to "upgrade" semantics in FontAwesome 6 unambiguously.
const upgradeBtn = page.locator('button[title="Upgrade backend on this node"]')
await page.getByRole('button', { name: `Actions for backend ${BACKEND_NAME}` }).click()
const upgradeBtn = page.getByRole('menuitem', { name: 'Upgrade backend' })
await expect(upgradeBtn).toBeVisible()
const iconClass = await upgradeBtn.locator('i').getAttribute('class')
expect(iconClass).toMatch(/fa-(arrow-up|arrows-rotate|up-long)/)
Expand All @@ -122,7 +152,8 @@ test.describe('Nodes page — per-node backend actions', () => {
await mockDistributedNodes(page)
await openNodeDetail(page)

const deleteBtn = page.locator('button[title="Delete backend from this node"]')
await page.getByRole('button', { name: `Actions for backend ${BACKEND_NAME}` }).click()
const deleteBtn = page.getByRole('menuitem', { name: 'Delete backend…' })
await expect(deleteBtn).toBeVisible()
await expect(deleteBtn.locator('i.fa-trash')).toBeVisible()
})
Expand All @@ -136,7 +167,8 @@ test.describe('Nodes page — per-node backend actions', () => {
})
await openNodeDetail(page)

await page.locator('button[title="Delete backend from this node"]').click()
await page.getByRole('button', { name: `Actions for backend ${BACKEND_NAME}` }).click()
await page.getByRole('menuitem', { name: 'Delete backend…' }).click()

// ConfirmDialog uses role="alertdialog" and a danger confirm button.
const dialog = page.getByRole('alertdialog')
Expand All @@ -158,7 +190,8 @@ test.describe('Nodes page — per-node backend actions', () => {
})
await openNodeDetail(page)

await page.locator('button[title="Delete backend from this node"]').click()
await page.getByRole('button', { name: `Actions for backend ${BACKEND_NAME}` }).click()
await page.getByRole('menuitem', { name: 'Delete backend…' }).click()

const dialog = page.getByRole('alertdialog')
await expect(dialog).toBeVisible()
Expand Down
24 changes: 5 additions & 19 deletions core/http/react-ui/e2e/nodes-roster.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,41 +35,27 @@ test.describe('Nodes fleet roster', () => {
})

test.describe('Nodes join command', () => {
// The panel emits BOTH the backend and the agent join command from one
// component. Neither worker kind dials a message bus any more: each holds one
// outward tunnel to --register-to and takes every verb on it. A join command
// carrying --nats-url would tell an operator to stand up, secure and pay for a
// broker that nothing in the deployment connects to, which is the one way this
// migration can still cost money after the code stopped using it.
//
// Asserted on the RENDERED command text rather than on the component's
// variables, because the variables are what the fix deletes: a spec reading
// them would stop compiling instead of failing, and a compile error is not
// evidence about what an operator is shown.
test('emits no bus flag for either worker kind', async ({ page }) => {
test('preserves the NATS connection in both worker command forms', async ({ page }) => {
await mockCluster(page, [])
await page.goto('/app/nodes')

await page.getByRole('radio', { name: /^Backend$/ }).click()
const backendCli = page.locator('.p2p-cmd pre').first()
await expect(backendCli).toContainText('local-ai worker', { timeout: 15_000 })
await expect(backendCli).not.toContainText('--nats-url')
await expect(backendCli).toContainText('--nats-url "nats://nats:4222"')
const backendDocker = page.locator('.p2p-cmd pre').nth(1)
await expect(backendDocker).toContainText('LOCALAI_REGISTER_TO')
await expect(backendDocker).not.toContainText('LOCALAI_NATS_URL')
await expect(backendDocker).toContainText('LOCALAI_NATS_URL="nats://nats:4222"')

// The agent tab is the one that regressed: it was the last surface still
// emitting the flag, and it kept emitting it for two tasks after the agent
// worker stopped dialling.
await page.getByRole('radio', { name: /^Agent$/ }).click()
const agentCli = page.locator('.p2p-cmd pre').first()
await expect(agentCli).toContainText('local-ai agent-worker', { timeout: 15_000 })
await expect(agentCli).toContainText('--register-to',
)
await expect(agentCli).not.toContainText('--nats-url')
await expect(agentCli).toContainText('--nats-url "nats://nats:4222"')
const agentDocker = page.locator('.p2p-cmd pre').nth(1)
await expect(agentDocker).toContainText('LOCALAI_REGISTER_TO')
await expect(agentDocker).not.toContainText('LOCALAI_NATS_URL')
await expect(agentDocker).toContainText('LOCALAI_NATS_URL="nats://nats:4222"')
})

test('does not advertise flags the CLI does not have', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion core/http/react-ui/inline-style-baseline.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
512
498
47 changes: 45 additions & 2 deletions core/http/react-ui/public/locales/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,58 @@
"backendLogs": {
"title": "Backend Logs",
"subtitle": "View logs from running backends",
"empty": "No logs available"
"empty": "No logs available",
"backToNodes": "Back to nodes",
"backToNode": "Back to {{name}}",
"noSelectionTitle": "No node or model selected",
"noSelectionPrefix": "View backend logs from the",
"nodesPage": "Nodes page",
"replica": "Replica {{number}}",
"merged": "Merged · {{count}} replicas",
"fromNode": "Backend logs from node",
"replicaScope": "Replica scope",
"allMerged": "All merged",
"allMergedHelp": "Show an interleaved timeline of all replicas — useful for comparing replica behavior side by side",
"filterAll": "All",
"export": "Export",
"clear": "Clear",
"cleared": "Logs cleared",
"textOnly": "Text only",
"showDetails": "Show details",
"hideDetailsHelp": "Hide timestamps and stream labels for easier copying",
"showDetailsHelp": "Show timestamps and stream labels",
"live": "Live",
"reconnecting": "Reconnecting…",
"autoScroll": "Auto-scroll",
"noLines": "No log lines",
"noFilteredLines": "No {{stream}} output. Try switching to All.",
"waitingForLines": "Log output will appear here as the backend process runs."
},
"traces": {
"title": "Traces",
"subtitle": "View logged API requests, responses, and backend operations"
},
"nodes": {
"title": "Distributed Nodes",
"subtitle": "Manage backend and agent worker nodes"
"subtitle": "Manage backend and agent worker nodes",
"selectionVisibility": "{{visible}} visible, {{hidden}} outside this view",
"workerSetup": {
"title": "Add worker",
"eyebrow": "Worker setup",
"description": "Choose a worker role and hardware target, then run one of the generated commands on the machine you want to connect.",
"close": "Close worker setup",
"type": "Worker type",
"backend": "Backend",
"agent": "Agent",
"hardware": "Hardware",
"cli": "CLI",
"docker": "Docker",
"copy": "Copy command",
"copied": "Copied to clipboard",
"docs": "Distributed mode documentation",
"emptyTitle": "No workers registered yet",
"emptyDescription": "Add a worker to bring compute capacity into this fleet."
}
},
"scheduling": {
"title": "Scheduling",
Expand Down
28 changes: 28 additions & 0 deletions core/http/react-ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -9863,6 +9863,7 @@ button.collapsible-header:focus-visible {
.fleet-select__chevron { color: var(--color-text-muted); font-size: .625rem; pointer-events: none; position: absolute; right: 10px; top: 12px; }
.fleet-bulkbar { align-items: center; background: var(--color-primary-light); border-bottom: 1px solid var(--color-border-strong); display: flex; flex-wrap: wrap; gap: var(--spacing-xs); min-height: 42px; padding: 5px 11px; }
.fleet-bulkbar strong { font-size: var(--text-xs); margin-right: var(--spacing-xs); }
.fleet-bulkbar__visibility { color: var(--color-text-secondary); font-size: var(--text-xs); }
.fleet-bulkbar__clear { background: none; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-size: var(--text-xs); }
.fleet-bulkbar__count { color: var(--color-text-muted); font-size: var(--text-xs); margin-left: auto; }
.fleet-table-wrap { overflow-x: auto; }
Expand Down Expand Up @@ -9981,6 +9982,31 @@ button.collapsible-header:focus-visible {
.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; }

.worker-setup { width: min(460px, calc(100vw - 2 * var(--spacing-md))); }
.worker-setup__types { width: 100%; }
.worker-setup__types .segmented__item { flex: 1; justify-content: center; }
.worker-setup__commands { display: grid; gap: var(--spacing-md); }
.worker-setup__commands .p2p-cmd pre { max-height: 180px; }

.node-backend-logs__title-icon { font-size: .8em; margin-right: var(--spacing-sm); }
.node-backend-logs__scope-tag { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); color: var(--color-text-secondary); font-size: .6875rem; font-weight: 500; margin-left: var(--spacing-sm); padding: 2px 8px; vertical-align: middle; }
.node-backend-logs__back { color: var(--color-primary); font-size: var(--text-xs); margin-left: var(--spacing-xs); }
.node-backend-logs__replicas { margin-bottom: var(--spacing-sm); }
.node-backend-logs__toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: var(--spacing-sm); margin-bottom: var(--spacing-md); }
.node-backend-logs__filters { display: flex; gap: 2px; }
.node-backend-logs__connection { align-items: center; display: flex; font-size: var(--text-xs); gap: var(--spacing-xs); margin-left: auto; }
.node-backend-logs__connection-dot { background: var(--color-text-muted); border-radius: 50%; display: inline-block; height: 8px; width: 8px; }
.node-backend-logs__connection-dot.is-live { background: var(--color-success); }
.node-backend-logs__autoscroll { align-items: center; cursor: pointer; display: flex; gap: 4px; margin-left: var(--spacing-sm); }
.node-backend-logs__output { background: var(--color-bg-primary); border: 1px solid var(--color-border); border-radius: var(--radius-md); font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.5; max-height: calc(100vh - 280px); overflow: auto; }
.node-backend-logs__line { align-items: flex-start; border-bottom: 1px solid var(--color-border-subtle); display: flex; padding: 2px var(--spacing-sm); }
.node-backend-logs__line.has-details { gap: var(--spacing-sm); }
.node-backend-logs__timestamp { color: var(--color-text-muted); flex-shrink: 0; min-width: 90px; }
.node-backend-logs__stream { border-radius: var(--radius-sm); flex-shrink: 0; font-size: .625rem; font-weight: 500; line-height: 1.5; padding: 0 4px; }
.node-backend-logs__stream--stdout { background: var(--color-info-light); color: var(--color-log-info); }
.node-backend-logs__stream--stderr { background: var(--color-error-light); color: var(--color-log-stderr); }
.node-backend-logs__text { flex: 1; white-space: pre-wrap; word-break: break-all; }

@container fleet-page (max-width: 760px) {
.fleet-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fleet-overview__cell { min-height: 146px; }
Expand Down Expand Up @@ -10017,6 +10043,8 @@ button.collapsible-header:focus-visible {
.node-inspector__header { padding-left: 18px; padding-right: 18px; padding-top: max(16px, env(safe-area-inset-top)); }
.node-inspector__body { padding-left: 18px; padding-right: 18px; }
.node-inspector__actions { padding-bottom: max(14px, env(safe-area-inset-bottom)); padding-left: 18px; padding-right: 18px; }
.worker-setup { width: 100vw; }
.node-backend-logs__connection { margin-left: 0; width: 100%; }
}
@keyframes node-inspector-scrim-in { from { opacity: 0; } to { opacity: 1; } }

Expand Down
Loading
Loading