diff --git a/core/http/react-ui/e2e/backend-logs.spec.js b/core/http/react-ui/e2e/backend-logs.spec.js index 0390aa6bcebe..9eaf3eced15c 100644 --- a/core/http/react-ui/e2e/backend-logs.spec.js +++ b/core/http/react-ui/e2e/backend-logs.spec.js @@ -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) + }) }) diff --git a/core/http/react-ui/e2e/nodes-detail.spec.js b/core/http/react-ui/e2e/nodes-detail.spec.js index 6793b3b9d860..1183f0416e27 100644 --- a/core/http/react-ui/e2e/nodes-detail.spec.js +++ b/core/http/react-ui/e2e/nodes-detail.spec.js @@ -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 }) => { 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 e7b0ef6e06b9..64eb7ea8ce37 100644 --- a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js +++ b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js @@ -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() @@ -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 @@ -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') @@ -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') diff --git a/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js b/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js index e790892d4d08..97b15cb47d78 100644 --- a/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js +++ b/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js @@ -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) @@ -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)/) @@ -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() }) @@ -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') @@ -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() diff --git a/core/http/react-ui/e2e/nodes-roster.spec.js b/core/http/react-ui/e2e/nodes-roster.spec.js index e815400a63bb..47e263e52c97 100644 --- a/core/http/react-ui/e2e/nodes-roster.spec.js +++ b/core/http/react-ui/e2e/nodes-roster.spec.js @@ -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 }) => { diff --git a/core/http/react-ui/inline-style-baseline.txt b/core/http/react-ui/inline-style-baseline.txt index 4d0e90cbcbaf..f9aaa4d565f4 100644 --- a/core/http/react-ui/inline-style-baseline.txt +++ b/core/http/react-ui/inline-style-baseline.txt @@ -1 +1 @@ -512 +498 diff --git a/core/http/react-ui/public/locales/en/admin.json b/core/http/react-ui/public/locales/en/admin.json index 20c9d70ad9aa..ec4cc1ced00f 100644 --- a/core/http/react-ui/public/locales/en/admin.json +++ b/core/http/react-ui/public/locales/en/admin.json @@ -180,7 +180,32 @@ "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", @@ -188,7 +213,25 @@ }, "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", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 83ce93ec3f9a..fc3d2068594a 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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; } @@ -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; } @@ -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; } } diff --git a/core/http/react-ui/src/pages/BackendLogs.jsx b/core/http/react-ui/src/pages/BackendLogs.jsx index 60e8e94381d3..35e0a39c79ce 100644 --- a/core/http/react-ui/src/pages/BackendLogs.jsx +++ b/core/http/react-ui/src/pages/BackendLogs.jsx @@ -376,7 +376,7 @@ function DistributedBackendLogsResolver({ modelId, fromTimestamp }) {
{node.name || node.id}
- {node.id}{model.replica_index ? ` · replica ${model.replica_index}` : ''} · {model.state} + {node.id}{model.replica_index != null ? ` · replica ${model.replica_index + 1}` : ''} · {model.state}
diff --git a/core/http/react-ui/src/pages/NodeBackendLogs.jsx b/core/http/react-ui/src/pages/NodeBackendLogs.jsx index bda0313cc42e..5028a83217dc 100644 --- a/core/http/react-ui/src/pages/NodeBackendLogs.jsx +++ b/core/http/react-ui/src/pages/NodeBackendLogs.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { useParams, useOutletContext, Link, useNavigate } from 'react-router-dom' +import { useParams, useOutletContext, Link, useNavigate, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { nodesApi } from '../utils/api' import { formatTimestamp } from '../utils/format' import { apiUrl } from '../utils/basePath' @@ -11,15 +12,16 @@ function wsUrl(path) { return `${proto}//${window.location.host}${apiUrl(path)}` } -const STREAM_BADGE = { - stdout: { bg: 'var(--color-info-light)', color: 'var(--color-log-info)', label: 'stdout' }, - stderr: { bg: 'var(--color-error-light)', color: 'var(--color-log-stderr)', label: 'stderr' }, -} +const STREAM_BADGE = { stdout: 'stdout', stderr: 'stderr' } export default function NodeBackendLogs() { const { nodeId, modelId = '' } = useParams() const { addToast } = useOutletContext() const navigate = useNavigate() + const location = useLocation() + const { t } = useTranslation('admin') + const requestedReturnPath = location.state?.from + const returnPath = typeof requestedReturnPath === 'string' && /^\/app\/nodes(?:\/[^/?#]+)?$/.test(requestedReturnPath) ? requestedReturnPath : '/app/nodes' // The route param can be a bare model name ("qwen3-0.6b") OR a per-replica // process key ("qwen3-0.6b#0"). The worker's BackendLogStore treats them @@ -154,15 +156,21 @@ export default function NodeBackendLogs() { URL.revokeObjectURL(url) } + const handleClear = () => { + setLines([]) + pendingLinesRef.current = [] + addToast(t('backendLogs.cleared'), 'success') + } + if (!nodeId || !modelId) { return (
-

No node/model selected

+

{t('backendLogs.noSelectionTitle')}

- View backend logs from the{' '} - Nodes page. + {t('backendLogs.noSelectionPrefix')}{' '} + {t('backendLogs.nodesPage')}.

@@ -175,58 +183,32 @@ export default function NodeBackendLogs() { const showReplicaToggle = replicas.length > 1 return ( -
+
- +