diff --git a/core/http/react-ui/e2e/activity-page.spec.js b/core/http/react-ui/e2e/activity-page.spec.js new file mode 100644 index 000000000..d0c5eac5c --- /dev/null +++ b/core/http/react-ui/e2e/activity-page.spec.js @@ -0,0 +1,417 @@ +import { test, expect } from './coverage-fixtures.js' + +const stub = (page, { operations = [], history = [] } = {}) => Promise.all([ + page.route('**/api/operations', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ operations }), + })), + page.route('**/api/operations/history', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ operations: history }), + })), +]) + +test('lists live operations and cancels one from a labelled button', async ({ page }) => { + await stub(page, { + operations: [{ + id: 'gemma-3-27b-it', + name: 'gemma-3-27b-it', + jobID: 'job-gemma', + progress: 22, + taskType: 'installation', + isBackend: false, + isQueued: false, + isDeletion: false, + cancellable: true, + phase: 'downloading', + }], + }) + + let cancelledPath = '' + await page.route('**/api/operations/job-gemma/cancel', (route) => { + cancelledPath = new URL(route.request().url()).pathname + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + + await page.goto('/app/activity') + + const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' }) + await expect(card).toBeVisible() + await expect(card).toContainText('22%') + + await card.locator('.operation-card__cancel').click() + expect(cancelledPath).toBe('/api/operations/job-gemma/cancel') +}) + +test('separates an unacknowledged failure from the record', async ({ page }) => { + await stub(page, { + operations: [{ + id: 'sherpa-onnx', + name: 'sherpa-onnx', + jobID: 'job-sherpa', + progress: 0, + taskType: 'installation', + isBackend: true, + isQueued: false, + isDeletion: false, + cancellable: false, + error: 'no space left on device', + }], + history: [{ + id: 'bark-cpp', + name: 'bark-cpp', + jobID: 'job-bark', + isBackend: true, + taskType: 'installation', + outcome: 'failed', + error: 'checksum mismatch', + startedAt: '2026-07-28T13:40:00Z', + finishedAt: '2026-07-28T13:41:00Z', + }], + }) + + await page.goto('/app/activity') + + // Live and unacknowledged: a card that needs a decision. + await expect(page.locator('.operation-card--error')).toContainText('sherpa-onnx') + // Dismissed earlier: a row in the record. + await expect(page.locator('.activity-row')).toContainText('bark-cpp') +}) + +test('a failure never appears in both In progress and Needs attention', async ({ page }) => { + // Section membership has to be unambiguous: the same job showing twice makes + // the two failure paths (retry / dismiss) impossible to reason about. + await stub(page, { + operations: [ + { + id: 'model-a', + name: 'model-a', + jobID: 'job-a', + progress: 40, + taskType: 'installation', + isBackend: false, + isQueued: false, + isDeletion: false, + cancellable: true, + }, + { + id: 'sherpa-onnx', + name: 'sherpa-onnx', + jobID: 'job-sherpa', + progress: 0, + taskType: 'installation', + isBackend: true, + isQueued: false, + isDeletion: false, + cancellable: false, + error: 'no space left on device', + }, + ], + }) + + await page.goto('/app/activity') + + await expect(page.locator('.operation-card')).toHaveCount(2) + await expect(page.locator('.operation-card').filter({ hasText: 'sherpa-onnx' })).toHaveCount(1) +}) + +test('retrying a failed backend install dismisses it before reinstalling', async ({ page }) => { + // Order is load-bearing: a bare reinstall overwrites the opcache entry + // without going through recordTerminal, so the failure would never reach the + // record. + await stub(page, { + operations: [{ + id: 'sherpa-onnx', + name: 'sherpa-onnx', + fullName: 'localai@sherpa-onnx', + jobID: 'job-sherpa', + progress: 0, + taskType: 'installation', + isBackend: true, + isQueued: false, + isDeletion: false, + cancellable: false, + error: 'no space left on device', + }], + }) + + const calls = [] + await page.route('**/api/operations/job-sherpa/dismiss', (route) => { + calls.push('dismiss') + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.route('**/api/backends/install/**', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + + await page.goto('/app/activity') + + await page.locator('.operation-card__retry').click() + + await expect.poll(() => calls).toEqual(['dismiss', '/api/backends/install/localai@sherpa-onnx']) +}) + +test('retry dismisses the job it was pressed on, not another sharing its id', async ({ page }) => { + // /api/operations strips the "node::" prefix, so a local install and a + // node-scoped install of one backend arrive with the same id and different + // jobIDs. Dismissing by id retired whichever came first, which both left the + // acted-on failure live and silently retired an unrelated one. + const failed = (over) => ({ + id: 'sherpa-onnx', + name: 'sherpa-onnx', + fullName: 'sherpa-onnx', + progress: 0, + taskType: 'installation', + isBackend: true, + isQueued: false, + isDeletion: false, + cancellable: false, + error: 'no space left on device', + ...over, + }) + await stub(page, { + operations: [ + failed({ jobID: 'job-local' }), + failed({ jobID: 'job-node', nodeID: 'node-1' }), + ], + }) + + const calls = [] + await page.route('**/api/operations/*/dismiss', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.route('**/api/nodes/*/backends/install', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + + await page.goto('/app/activity') + + // Nothing on screen tells the two cards apart, which is the point: they + // share a name and an id, and only the jobID behind each one differs. The + // node-scoped job is second in the payload, so it is the second card. + await expect(page.locator('.operation-card')).toHaveCount(2) + await page.locator('.operation-card').nth(1).locator('.operation-card__retry').click() + + await expect.poll(() => calls).toEqual([ + '/api/operations/job-node/dismiss', + '/api/nodes/node-1/backends/install', + ]) +}) + +test('the dismiss control also acts on the job it belongs to', async ({ page }) => { + // Same hazard as retry: the card's X passed the display id too. + const failed = (over) => ({ + id: 'sherpa-onnx', + name: 'sherpa-onnx', + fullName: 'sherpa-onnx', + progress: 0, + taskType: 'installation', + isBackend: true, + isQueued: false, + isDeletion: false, + cancellable: false, + error: 'no space left on device', + ...over, + }) + await stub(page, { + operations: [ + failed({ jobID: 'job-local' }), + failed({ jobID: 'job-node', nodeID: 'node-1' }), + ], + }) + + const dismissed = [] + await page.route('**/api/operations/*/dismiss', (route) => { + dismissed.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + + await page.goto('/app/activity') + + await expect(page.locator('.operation-card')).toHaveCount(2) + await page.locator('.operation-card').nth(1).locator('.operation-card__hide').click() + + await expect.poll(() => dismissed).toEqual(['/api/operations/job-node/dismiss']) +}) + +test('a filter matching nothing does not claim the instance is empty', async ({ page }) => { + // Three model records on file: telling the user nothing has ever run, while + // the header counts those same three, is simply false. + await stub(page, { + history: [1, 2, 3].map((n) => ({ + id: `model-${n}`, + name: `model-${n}`, + jobID: `job-${n}`, + isBackend: false, + taskType: 'installation', + outcome: 'completed', + startedAt: '2026-07-28T13:40:00Z', + finishedAt: '2026-07-28T13:40:20Z', + })), + }) + + await page.goto('/app/activity') + await expect(page.locator('.activity-row')).toHaveCount(3) + + await page.locator('.activity-chip', { hasText: 'Backends' }).click() + + await expect(page.locator('.activity-empty--filtered')).toBeVisible() + await expect(page.locator('.activity-empty')).not.toContainText('No operations since startup') + // And the way back out is on screen. + await page.locator('.activity-empty--filtered button').click() + await expect(page.locator('.activity-row')).toHaveCount(3) +}) + +test('the summary drops a zero count instead of reporting it', async ({ page }) => { + await stub(page, { + operations: [{ + id: 'model-a', + name: 'model-a', + jobID: 'job-a', + progress: 40, + taskType: 'installation', + isBackend: false, + isQueued: false, + isDeletion: false, + cancellable: true, + }], + }) + + await page.goto('/app/activity') + + const supporting = page.locator('.page-header__supporting') + await expect(supporting).toHaveText('1 operation running.') + await expect(supporting).not.toContainText('0') +}) + +test('a cancelled deletion reports the cancellation, not a removal', async ({ page }) => { + await stub(page, { + history: [{ + id: 'model-a', + name: 'model-a', + jobID: 'job-a', + isBackend: false, + taskType: 'deletion', + outcome: 'cancelled', + startedAt: '2026-07-28T13:40:00Z', + finishedAt: '2026-07-28T13:40:02Z', + }], + }) + + await page.goto('/app/activity') + + await expect(page.locator('.activity-row')).toContainText('cancelled') + await expect(page.locator('.activity-row')).not.toContainText('removed') +}) + +test('an implausible or zero duration never reaches the row', async ({ page }) => { + await stub(page, { + history: [ + { + id: 'zero-span', + name: 'zero-span', + jobID: 'job-zero', + isBackend: false, + taskType: 'installation', + outcome: 'completed', + // recordTerminal seeds StartedAt = FinishedAt and only overwrites it + // with a real stamp, so this is an ordinary arrival. + startedAt: '2026-07-28T13:40:00Z', + finishedAt: '2026-07-28T13:40:00Z', + }, + { + id: 'zero-stamp', + name: 'zero-stamp', + jobID: 'job-stamp', + isBackend: false, + taskType: 'installation', + outcome: 'completed', + startedAt: '0001-01-01T00:00:00Z', + finishedAt: '2026-07-28T13:41:00Z', + }, + ], + }) + + await page.goto('/app/activity') + + const zeroSpan = page.locator('.activity-row').filter({ hasText: 'zero-span' }) + await expect(zeroSpan).toContainText('installed in < 1s') + + // A zero-value Go stamp is not a duration. The row says what happened and + // stops, rather than stating a span of millennia as fact. + const zeroStamp = page.locator('.activity-row').filter({ hasText: 'zero-stamp' }) + await expect(zeroStamp).toContainText('installed') + await expect(zeroStamp).not.toContainText('installed in') +}) + +test('a failed removal offers no retry, because retry only means install', async ({ page }) => { + await stub(page, { + operations: [{ + id: 'model-a', + name: 'model-a', + fullName: 'model-a', + jobID: 'job-a', + progress: 0, + taskType: 'deletion', + isBackend: false, + isQueued: false, + isDeletion: true, + cancellable: false, + error: 'file is busy', + }], + }) + + await page.goto('/app/activity') + + await expect(page.locator('.operation-card--error')).toBeVisible() + await expect(page.locator('.operation-card__retry')).toHaveCount(0) + // And it must not claim an install was attempted. + await expect(page.locator('.operation-card--error')).not.toContainText('install') +}) + +test('filters the record down to backends', async ({ page }) => { + await stub(page, { + history: [ + { + id: 'gemma-3-27b-it', + name: 'gemma-3-27b-it', + jobID: 'job-gemma', + isBackend: false, + taskType: 'installation', + outcome: 'completed', + startedAt: '2026-07-28T13:40:00Z', + finishedAt: '2026-07-28T13:41:30Z', + }, + { + id: 'bark-cpp', + name: 'bark-cpp', + jobID: 'job-bark', + isBackend: true, + taskType: 'installation', + outcome: 'completed', + startedAt: '2026-07-28T13:40:00Z', + finishedAt: '2026-07-28T13:40:20Z', + }, + ], + }) + + await page.goto('/app/activity') + await expect(page.locator('.activity-row')).toHaveCount(2) + + await page.locator('.activity-chip', { hasText: 'Backends' }).click() + + await expect(page.locator('.activity-row')).toHaveCount(1) + await expect(page.locator('.activity-row')).toContainText('bark-cpp') +}) + +test('shows the empty state when nothing has run', async ({ page }) => { + await stub(page) + + await page.goto('/app/activity') + + await expect(page.locator('.page-title')).toBeVisible() + await expect(page.locator('.activity-empty')).toBeVisible() +}) diff --git a/core/http/react-ui/e2e/model-artifact-operation.spec.js b/core/http/react-ui/e2e/model-artifact-operation.spec.js index 22cbfec5c..c711c983e 100644 --- a/core/http/react-ui/e2e/model-artifact-operation.spec.js +++ b/core/http/react-ui/e2e/model-artifact-operation.spec.js @@ -1,6 +1,6 @@ import { test, expect } from './coverage-fixtures.js' -test('operations bar shows managed model acquisition phase and bytes', async ({ page }) => { +test('operations strip shows managed model acquisition phase and bytes', async ({ page }) => { await page.route('**/api/operations', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ @@ -14,7 +14,6 @@ test('operations bar shows managed model acquisition phase and bytes', async ({ isDeletion: false, isBackend: false, isQueued: false, - isCancelled: false, cancellable: true, phase: 'downloading', currentBytes: 1073741824, @@ -22,19 +21,12 @@ test('operations bar shows managed model acquisition phase and bytes', async ({ }], }), })) - let cancelledPath = '' - await page.route('**/api/operations/artifact-job-123/cancel', (route) => { - cancelledPath = new URL(route.request().url()).pathname - return route.fulfill({ contentType: 'application/json', body: '{}' }) - }) await page.goto('/app/models') - const operation = page.locator('.operation-item').filter({ hasText: 'qwen-asr' }) - await expect(operation).toContainText('Downloading model files') - await expect(operation).toContainText('1 GB / 4 GB') - await expect(operation.locator('.operation-progress')).toHaveText('45%') - await expect(operation.locator('.operation-bar')).toHaveAttribute('style', /width: 45%/) - - await operation.getByTitle('Cancel').click() - expect(cancelledPath).toBe('/api/operations/artifact-job-123/cancel') + const strip = page.locator('.operations-strip') + await expect(strip.locator('.operations-strip__name')).toHaveText('qwen-asr') + await expect(strip).toContainText('Downloading') + await expect(strip).toContainText('1 GB / 4 GB') + await expect(strip.locator('.operations-strip__pct')).toHaveText('45%') + await expect(strip.locator('.operations-strip__fill')).toHaveAttribute('style', /width: 45%/) }) diff --git a/core/http/react-ui/e2e/operations-strip.spec.js b/core/http/react-ui/e2e/operations-strip.spec.js new file mode 100644 index 000000000..95ee0c2ed --- /dev/null +++ b/core/http/react-ui/e2e/operations-strip.spec.js @@ -0,0 +1,216 @@ +import { test, expect } from './coverage-fixtures.js' + +const op = (over = {}) => ({ + id: 'model-a', + name: 'model-a', + fullName: 'model-a', + jobID: 'job-a', + progress: 40, + taskType: 'installation', + isDeletion: false, + isBackend: false, + isQueued: false, + cancellable: true, + ...over, +}) + +const stubOperations = (page, operations) => + page.route('**/api/operations', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ operations }), + })) + +test('renders exactly one row for four concurrent operations', async ({ page }) => { + await stubOperations(page, [ + op({ id: 'model-a', name: 'model-a', jobID: 'job-a', progress: 10 }), + op({ id: 'model-b', name: 'model-b', jobID: 'job-b', progress: 40 }), + op({ id: 'model-c', name: 'model-c', jobID: 'job-c', progress: 70 }), + op({ id: 'model-d', name: 'model-d', jobID: 'job-d', progress: 90 }), + ]) + + await page.goto('/app/models') + + // One row, never four. The stacked bar is what this replaces. + await expect(page.locator('.operations-strip')).toHaveCount(1) + // The API sorts by progress ascending, so the least advanced op leads. + await expect(page.locator('.operations-strip__name')).toHaveText('model-a') + await expect(page.locator('.operations-strip__more')).toContainText('3') + await expect(page.locator('.operations-strip__more')).toHaveAttribute('href', /\/app\/activity$/) +}) + +test('a failure takes the strip over a running install', async ({ page }) => { + await stubOperations(page, [ + op({ id: 'model-a', name: 'model-a', jobID: 'job-a', progress: 10 }), + op({ id: 'sherpa-onnx', name: 'sherpa-onnx', jobID: 'job-f', isBackend: true, error: 'no space left on device' }), + ]) + + await page.goto('/app/models') + + await expect(page.locator('.operations-strip__name')).toHaveText('sherpa-onnx') + await expect(page.locator('.operations-strip')).toContainText('no space left on device') +}) + +test('a hidden strip comes back when a different operation becomes primary', async ({ page }) => { + // Hiding must not be able to silence a later failure, so the hidden state is + // keyed by job rather than being a blanket mute. + // + // The swap is driven by the test rather than by a poll count: a count would + // race the click, and a click landing on the failure would take the dismiss + // path and fail this test for an unrelated reason. + let swapped = false + await page.route('**/api/operations', (route) => { + const operations = swapped + ? [op({ id: 'sherpa-onnx', name: 'sherpa-onnx', jobID: 'job-f', error: 'no space left on device' })] + : [op({ id: 'model-a', name: 'model-a', jobID: 'job-a' })] + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ operations }) }) + }) + + await page.goto('/app/models') + await expect(page.locator('.operations-strip__name')).toHaveText('model-a') + + await page.locator('.operations-strip__hide').click() + await expect(page.locator('.operations-strip')).toHaveCount(0) + + // The poller swaps in a different job, which must re-render the strip. + swapped = true + await expect(page.locator('.operations-strip__name')).toHaveText('sherpa-onnx', { timeout: 10_000 }) +}) + +test('hiding a running operation does not silence that same job failing', async ({ page }) => { + // A job keeps its jobID when it fails, so keying the hidden state on the job + // alone would let a user mute the very failure they need to see. + let failing = false + await page.route('**/api/operations', (route) => { + const operations = failing + ? [op({ error: 'no space left on device' })] + : [op()] + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ operations }) }) + }) + + await page.goto('/app/models') + await expect(page.locator('.operations-strip__name')).toHaveText('model-a') + + await page.locator('.operations-strip__hide').click() + await expect(page.locator('.operations-strip')).toHaveCount(0) + + failing = true + await expect(page.locator('.operations-strip')).toContainText('no space left on device', { timeout: 10_000 }) +}) + +test('a long error message does not widen the page', async ({ page }) => { + // An install error is arbitrarily long text, and the strip sits above every + // page: if it cannot shrink, every page under it gets a horizontal scrollbar. + await stubOperations(page, [op({ error: `disk write failed: ${'x'.repeat(180)}` })]) + + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto('/app/models') + await expect(page.locator('.operations-strip')).toBeVisible() + + const widths = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + client: document.documentElement.clientWidth, + })) + expect(widths.scroll).toBeLessThanOrEqual(widths.client) +}) + +test('progress is exposed to assistive tech as a named progressbar', async ({ page }) => { + // The percentage text is aria-hidden so the live region stops re-announcing + // the strip once a second; the value has to reach assistive tech some other + // way, and a progressbar is read on demand rather than announced. + await stubOperations(page, [op({ progress: 45 })]) + + await page.goto('/app/models') + const bar = page.locator('.operations-strip__track') + await expect(bar).toHaveAttribute('role', 'progressbar') + await expect(bar).toHaveAttribute('aria-valuenow', '45') + await expect(bar).toHaveAttribute('aria-valuemin', '0') + await expect(bar).toHaveAttribute('aria-valuemax', '100') + await expect(bar).toHaveAttribute('aria-label', /model-a/) +}) + +test('an operation waiting for the worker says queued, not installing', async ({ page }) => { + // The real payload for an admitted-but-unstarted op: phase "queued", no + // progress. It used to arrive with isQueued false, so the one state the + // strip has a clock icon for never appeared and a queued install claimed to + // be running. + await stubOperations(page, [op({ isQueued: true, phase: 'queued', progress: 0 })]) + + await page.goto('/app/models') + await expect(page.locator('.operations-strip')).toContainText('Queued') + await expect(page.locator('.operations-strip')).not.toContainText('Installing') + await expect(page.locator('.operations-strip__pct')).toHaveCount(0) +}) + +test('cancelling the last operation does not announce it as installed', async ({ page }) => { + // Cancelling deletes the operation server side, so the strip sees exactly + // what it sees when an install finishes: the operation stops being listed. + // The completion hold used to take that for success and put a green + // "Installed model model-a" on screen for four seconds after the user + // called it off. + let cancelled = false + await page.route('**/api/operations', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ operations: cancelled ? [] : [op()] }), + })) + await page.route('**/api/operations/history', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ operations: [] }), + })) + await page.route('**/api/operations/job-a/cancel', (route) => { + cancelled = true + return route.fulfill({ contentType: 'application/json', body: '{"success":true}' }) + }) + + // Cancel is on the card, never on the strip, so the page is where the user + // does this. The strip sits above it the whole time. + await page.goto('/app/activity') + await expect(page.locator('.operations-strip')).toContainText('Installing model') + + await page.locator('.operation-card__cancel').click() + + // The poll that empties the page is the same render that would raise the + // completion hold, so the strip has to be counted the instant the card + // goes. A retrying assertion would simply wait the four-second hold out and + // pass on the regression it is here to catch. + await expect(page.locator('.operation-card')).toHaveCount(0) + expect(await page.locator('.operations-strip').count()).toBe(0) + + // And it must not turn up a moment later either. + await page.waitForTimeout(1500) + expect(await page.locator('.operations-strip').count()).toBe(0) +}) + +test('a completed removal does not announce an install', async ({ page }) => { + // The API drops an operation the instant it finishes, so the completion + // phrase is rendered from the operation the strip was already holding. + let removed = false + await page.route('**/api/operations', (route) => { + const operations = removed ? [] : [op({ isDeletion: true, progress: 0 })] + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ operations }) }) + }) + + await page.goto('/app/models') + await expect(page.locator('.operations-strip')).toContainText('Removing model') + + removed = true + await expect(page.locator('.operations-strip')).toContainText('Removed model', { timeout: 10_000 }) + await expect(page.locator('.operations-strip')).not.toContainText('Installed') +}) + +test('the hide button hides the strip without cancelling', async ({ page }) => { + await stubOperations(page, [op()]) + + let cancelCalled = false + await page.route('**/api/operations/*/cancel', (route) => { + cancelCalled = true + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + + await page.goto('/app/models') + await expect(page.locator('.operations-strip')).toBeVisible() + + await page.locator('.operations-strip__hide').click() + + await expect(page.locator('.operations-strip')).toHaveCount(0) + expect(cancelCalled).toBe(false) +}) diff --git a/core/http/react-ui/e2e/page-render-smoke.spec.js b/core/http/react-ui/e2e/page-render-smoke.spec.js index 6066f0a54..27f006b98 100644 --- a/core/http/react-ui/e2e/page-render-smoke.spec.js +++ b/core/http/react-ui/e2e/page-render-smoke.spec.js @@ -19,6 +19,7 @@ const PAGES = [ ['/app/studio', 'Studio'], ['/app/manage', 'Manage'], ['/app/backends', 'Backends'], + ['/app/activity', 'Activity'], ['/app/settings', 'Settings'], ['/app/nodes', 'Nodes'], ['/app/scheduling', 'Scheduling'], diff --git a/core/http/react-ui/e2e/real-binary-activity.spec.js b/core/http/react-ui/e2e/real-binary-activity.spec.js new file mode 100644 index 000000000..12d37f6cf --- /dev/null +++ b/core/http/react-ui/e2e/real-binary-activity.spec.js @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test' + +// Runs against a REAL local-ai binary with NO route stubbing. +// +// Every other spec here stubs /api/operations. That is how a payload the server +// could not actually emit (isDeletion:true on a live operation) stayed green +// through an entire review while the UI rendered a removal as an install. These +// assertions are only worth anything because the data came from the real handler. +// +// make build +// ./local-ai run --address 127.0.0.1:8089 --models-path /tmp/lai-e2e +// cd core/http/react-ui +// LOCALAI_REAL_BINARY=1 PLAYWRIGHT_EXTERNAL_SERVER=1 PW_WORKERS=1 \ +// npx playwright test e2e/real-binary-activity.spec.js +// +// Skipped by default so CI, which runs the stub server, is unaffected. +test.skip(!process.env.LOCALAI_REAL_BINARY, 'needs a real local-ai on 127.0.0.1:8089') +test.describe.configure({ mode: 'serial' }) + +const MISSING = 'definitely-not-a-real-model-xyz' + +test('a real failed install lands in Needs attention with the real error and a Retry', async ({ page, request }) => { + // Self-contained: the gallery resolver rejects an unknown name, so this fails + // fast without downloading anything. + await request.post(`/api/models/install/${MISSING}`) + + await page.goto('/app/activity') + await expect(page.locator('.page-title')).toBeVisible() + + const card = page.locator('.operation-card--error').filter({ hasText: MISSING }) + await expect(card).toBeVisible({ timeout: 20_000 }) + await expect(card).toContainText('no model found with name') + // Retry is offered for a failed install; it is gated off for a failed removal. + await expect(card.locator('.operation-card__retry')).toBeVisible() +}) + +test('the strip shows the real failure on one line and does not widen the page', async ({ page }) => { + await page.goto('/app/activity') + + const strip = page.locator('.operations-strip') + await expect(strip).toHaveCount(1) + await expect(strip).toHaveClass(/operations-strip--error/) + await expect(strip.locator('.operations-strip__name')).toHaveText(MISSING) + + const box = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + client: document.documentElement.clientWidth, + })) + expect(box.scroll).toBeLessThanOrEqual(box.client) +}) + +test('dismissing moves the failure into the record instead of destroying it', async ({ page }) => { + await page.goto('/app/activity') + + const card = page.locator('.operation-card--error').filter({ hasText: MISSING }) + await expect(card).toBeVisible() + await card.locator('.operation-card__hide').click() + + await expect(page.locator('.operation-card--error')).toHaveCount(0, { timeout: 15_000 }) + const row = page.locator('.activity-row').filter({ hasText: MISSING }) + await expect(row).toBeVisible({ timeout: 15_000 }) + await expect(row).toContainText('failed') +}) + +test('Clear history empties the record against the real store', async ({ page }) => { + await page.goto('/app/activity') + + await expect(page.locator('.activity-row').first()).toBeVisible() + await page.getByRole('button', { name: /clear history/i }).click() + + await expect(page.locator('.activity-row')).toHaveCount(0, { timeout: 15_000 }) + await expect(page.locator('.activity-empty')).toBeVisible() +}) diff --git a/core/http/react-ui/public/locales/de/admin.json b/core/http/react-ui/public/locales/de/admin.json index a0d2f1226..58becfd02 100644 --- a/core/http/react-ui/public/locales/de/admin.json +++ b/core/http/react-ui/public/locales/de/admin.json @@ -1,4 +1,84 @@ { + "activity": { + "title": "Activity", + "supporting": "Installs, downloads and removals on this instance.", + "hide": "Hide", + "moveToHistory": "Move to history", + "moreCount": "{{count}} more", + "waitingForInstaller": "waiting for the installer", + "progressLabel": "Progress for {{name}}", + "toNode": "to {{node}}", + "nodesDone": "{{done}} of {{total}} nodes done", + "timeLeft": "{{value}} left", + "cancel": "Cancel", + "cancelLabel": "Cancel {{name}}", + "retry": "Retry", + "retryLabel": "Retry {{name}}", + "nodeCount": "{{count}} nodes", + "showNodes": "Show {{count}} nodes", + "hideNodes": "Hide per-node detail", + "node": { + "done": "Done", + "failed": "Failed", + "queued": "Queued", + "workerBusy": "Worker busy", + "downloading": "Downloading" + }, + "kind": { + "model": "model", + "backend": "backend" + }, + "verb": { + "installing": "Installing {{kind}}", + "installed": "Installed {{kind}}", + "removed": "Removed {{kind}}", + "staged": "Staged model", + "failed": "Couldn't install {{kind}}", + "queued": "Queued", + "staging": "Staging model", + "removing": "Removing {{kind}}", + "failedRemoval": "Couldn't remove {{kind}}", + "failedStaging": "Couldn't stage model" + }, + "phase": { + "resolving": "Resolving files", + "downloading": "Downloading", + "verifying": "Verifying", + "committing": "Finalizing", + "persisting": "Saving configuration" + }, + "clearHistory": "Clear history", + "inProgress": "In progress", + "needsAttention": "Needs attention", + "record": "Record", + "historyNote": "Keeps the last 50 operations. History is in memory and resets when LocalAI restarts.", + "emptyTitle": "No operations since startup", + "emptyBody": "Model and backend installs appear here while they run and stay as a record afterwards. History is kept in memory, so it resets when LocalAI restarts.", + "browseModels": "Browse models", + "viewInModels": "View in Models", + "viewInBackends": "View in Backends", + "rowInstalled": "installed in {{duration}}", + "rowFailed": "failed: {{error}}", + "rowCancelled": "cancelled", + "rowRemoved": "removed", + "retryFailed": "Retry failed: {{message}}", + "filter": { + "all": "All", + "models": "Models", + "backends": "Backends", + "cluster": "Cluster" + }, + "summaryRunning_one": "{{count}} operation running.", + "summaryRunning_other": "{{count}} operations running.", + "summaryFailed_one": "{{count}} operation needs attention.", + "summaryFailed_other": "{{count}} operations need attention.", + "summaryQuiet_one": "Nothing running. {{count}} operation since startup.", + "summaryQuiet_other": "Nothing running. {{count}} operations since startup.", + "summaryIdle": "Nothing running.", + "emptyFiltered": "No operations match this filter.", + "showAll": "Show all", + "rowInstalledPlain": "installed" + }, "manage": { "title": "System", "subtitle": "Verwalten Sie installierte Modelle und Backends" diff --git a/core/http/react-ui/public/locales/de/nav.json b/core/http/react-ui/public/locales/de/nav.json index f2950da2d..af1d8b0ea 100644 --- a/core/http/react-ui/public/locales/de/nav.json +++ b/core/http/react-ui/public/locales/de/nav.json @@ -23,7 +23,8 @@ "cluster": "Cluster", "observability": "Observability", "access": "Access", - "system": "System" + "system": "System", + "activity": "Activity" }, "items": { "home": "Start", @@ -55,7 +56,8 @@ "system": "System", "settings": "Einstellungen", "api": "API", - "middleware": "Middleware" + "middleware": "Middleware", + "activity": "Aktivität" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/en/admin.json b/core/http/react-ui/public/locales/en/admin.json index 3dc860e1c..f6dccf80b 100644 --- a/core/http/react-ui/public/locales/en/admin.json +++ b/core/http/react-ui/public/locales/en/admin.json @@ -1,4 +1,84 @@ { + "activity": { + "title": "Activity", + "supporting": "Installs, downloads and removals on this instance.", + "hide": "Hide", + "moveToHistory": "Move to history", + "moreCount": "{{count}} more", + "waitingForInstaller": "waiting for the installer", + "progressLabel": "Progress for {{name}}", + "toNode": "to {{node}}", + "nodesDone": "{{done}} of {{total}} nodes done", + "timeLeft": "{{value}} left", + "cancel": "Cancel", + "cancelLabel": "Cancel {{name}}", + "retry": "Retry", + "retryLabel": "Retry {{name}}", + "nodeCount": "{{count}} nodes", + "showNodes": "Show {{count}} nodes", + "hideNodes": "Hide per-node detail", + "node": { + "done": "Done", + "failed": "Failed", + "queued": "Queued", + "workerBusy": "Worker busy", + "downloading": "Downloading" + }, + "kind": { + "model": "model", + "backend": "backend" + }, + "verb": { + "installing": "Installing {{kind}}", + "installed": "Installed {{kind}}", + "removed": "Removed {{kind}}", + "staged": "Staged model", + "failed": "Couldn't install {{kind}}", + "queued": "Queued", + "staging": "Staging model", + "removing": "Removing {{kind}}", + "failedRemoval": "Couldn't remove {{kind}}", + "failedStaging": "Couldn't stage model" + }, + "phase": { + "resolving": "Resolving files", + "downloading": "Downloading", + "verifying": "Verifying", + "committing": "Finalizing", + "persisting": "Saving configuration" + }, + "clearHistory": "Clear history", + "inProgress": "In progress", + "needsAttention": "Needs attention", + "record": "Record", + "historyNote": "Keeps the last 50 operations. History is in memory and resets when LocalAI restarts.", + "emptyTitle": "No operations since startup", + "emptyBody": "Model and backend installs appear here while they run and stay as a record afterwards. History is kept in memory, so it resets when LocalAI restarts.", + "browseModels": "Browse models", + "viewInModels": "View in Models", + "viewInBackends": "View in Backends", + "rowInstalled": "installed in {{duration}}", + "rowFailed": "failed: {{error}}", + "rowCancelled": "cancelled", + "rowRemoved": "removed", + "retryFailed": "Retry failed: {{message}}", + "filter": { + "all": "All", + "models": "Models", + "backends": "Backends", + "cluster": "Cluster" + }, + "summaryRunning_one": "{{count}} operation running.", + "summaryRunning_other": "{{count}} operations running.", + "summaryFailed_one": "{{count}} operation needs attention.", + "summaryFailed_other": "{{count}} operations need attention.", + "summaryQuiet_one": "Nothing running. {{count}} operation since startup.", + "summaryQuiet_other": "Nothing running. {{count}} operations since startup.", + "summaryIdle": "Nothing running.", + "emptyFiltered": "No operations match this filter.", + "showAll": "Show all", + "rowInstalledPlain": "installed" + }, "manage": { "title": "System", "subtitle": "Manage installed models and backends" diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index d878e3c0c..a899532d4 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -23,7 +23,8 @@ "cluster": "Cluster", "observability": "Observability", "access": "Access", - "system": "System" + "system": "System", + "activity": "Activity" }, "items": { "home": "Home", @@ -56,7 +57,8 @@ "swarm": "Swarm", "system": "System", "settings": "Settings", - "api": "API" + "api": "API", + "activity": "Activity" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/es/admin.json b/core/http/react-ui/public/locales/es/admin.json index 47177fd8b..b1183b24e 100644 --- a/core/http/react-ui/public/locales/es/admin.json +++ b/core/http/react-ui/public/locales/es/admin.json @@ -1,4 +1,84 @@ { + "activity": { + "title": "Activity", + "supporting": "Installs, downloads and removals on this instance.", + "hide": "Hide", + "moveToHistory": "Move to history", + "moreCount": "{{count}} more", + "waitingForInstaller": "waiting for the installer", + "progressLabel": "Progress for {{name}}", + "toNode": "to {{node}}", + "nodesDone": "{{done}} of {{total}} nodes done", + "timeLeft": "{{value}} left", + "cancel": "Cancel", + "cancelLabel": "Cancel {{name}}", + "retry": "Retry", + "retryLabel": "Retry {{name}}", + "nodeCount": "{{count}} nodes", + "showNodes": "Show {{count}} nodes", + "hideNodes": "Hide per-node detail", + "node": { + "done": "Done", + "failed": "Failed", + "queued": "Queued", + "workerBusy": "Worker busy", + "downloading": "Downloading" + }, + "kind": { + "model": "model", + "backend": "backend" + }, + "verb": { + "installing": "Installing {{kind}}", + "installed": "Installed {{kind}}", + "removed": "Removed {{kind}}", + "staged": "Staged model", + "failed": "Couldn't install {{kind}}", + "queued": "Queued", + "staging": "Staging model", + "removing": "Removing {{kind}}", + "failedRemoval": "Couldn't remove {{kind}}", + "failedStaging": "Couldn't stage model" + }, + "phase": { + "resolving": "Resolving files", + "downloading": "Downloading", + "verifying": "Verifying", + "committing": "Finalizing", + "persisting": "Saving configuration" + }, + "clearHistory": "Clear history", + "inProgress": "In progress", + "needsAttention": "Needs attention", + "record": "Record", + "historyNote": "Keeps the last 50 operations. History is in memory and resets when LocalAI restarts.", + "emptyTitle": "No operations since startup", + "emptyBody": "Model and backend installs appear here while they run and stay as a record afterwards. History is kept in memory, so it resets when LocalAI restarts.", + "browseModels": "Browse models", + "viewInModels": "View in Models", + "viewInBackends": "View in Backends", + "rowInstalled": "installed in {{duration}}", + "rowFailed": "failed: {{error}}", + "rowCancelled": "cancelled", + "rowRemoved": "removed", + "retryFailed": "Retry failed: {{message}}", + "filter": { + "all": "All", + "models": "Models", + "backends": "Backends", + "cluster": "Cluster" + }, + "summaryRunning_one": "{{count}} operation running.", + "summaryRunning_other": "{{count}} operations running.", + "summaryFailed_one": "{{count}} operation needs attention.", + "summaryFailed_other": "{{count}} operations need attention.", + "summaryQuiet_one": "Nothing running. {{count}} operation since startup.", + "summaryQuiet_other": "Nothing running. {{count}} operations since startup.", + "summaryIdle": "Nothing running.", + "emptyFiltered": "No operations match this filter.", + "showAll": "Show all", + "rowInstalledPlain": "installed" + }, "manage": { "title": "Sistema", "subtitle": "Administra modelos y backends instalados" diff --git a/core/http/react-ui/public/locales/es/nav.json b/core/http/react-ui/public/locales/es/nav.json index a1ed97bca..8318fb90a 100644 --- a/core/http/react-ui/public/locales/es/nav.json +++ b/core/http/react-ui/public/locales/es/nav.json @@ -23,7 +23,8 @@ "cluster": "Cluster", "observability": "Observability", "access": "Access", - "system": "System" + "system": "System", + "activity": "Activity" }, "items": { "home": "Inicio", @@ -55,7 +56,8 @@ "system": "Sistema", "settings": "Configuración", "api": "API", - "middleware": "Middleware" + "middleware": "Middleware", + "activity": "Actividad" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/id/admin.json b/core/http/react-ui/public/locales/id/admin.json index 7e9460f1d..7a11b0677 100644 --- a/core/http/react-ui/public/locales/id/admin.json +++ b/core/http/react-ui/public/locales/id/admin.json @@ -1,4 +1,84 @@ { + "activity": { + "title": "Activity", + "supporting": "Installs, downloads and removals on this instance.", + "hide": "Hide", + "moveToHistory": "Move to history", + "moreCount": "{{count}} more", + "waitingForInstaller": "waiting for the installer", + "progressLabel": "Progress for {{name}}", + "toNode": "to {{node}}", + "nodesDone": "{{done}} of {{total}} nodes done", + "timeLeft": "{{value}} left", + "cancel": "Cancel", + "cancelLabel": "Cancel {{name}}", + "retry": "Retry", + "retryLabel": "Retry {{name}}", + "nodeCount": "{{count}} nodes", + "showNodes": "Show {{count}} nodes", + "hideNodes": "Hide per-node detail", + "node": { + "done": "Done", + "failed": "Failed", + "queued": "Queued", + "workerBusy": "Worker busy", + "downloading": "Downloading" + }, + "kind": { + "model": "model", + "backend": "backend" + }, + "verb": { + "installing": "Installing {{kind}}", + "installed": "Installed {{kind}}", + "removed": "Removed {{kind}}", + "staged": "Staged model", + "failed": "Couldn't install {{kind}}", + "queued": "Queued", + "staging": "Staging model", + "removing": "Removing {{kind}}", + "failedRemoval": "Couldn't remove {{kind}}", + "failedStaging": "Couldn't stage model" + }, + "phase": { + "resolving": "Resolving files", + "downloading": "Downloading", + "verifying": "Verifying", + "committing": "Finalizing", + "persisting": "Saving configuration" + }, + "clearHistory": "Clear history", + "inProgress": "In progress", + "needsAttention": "Needs attention", + "record": "Record", + "historyNote": "Keeps the last 50 operations. History is in memory and resets when LocalAI restarts.", + "emptyTitle": "No operations since startup", + "emptyBody": "Model and backend installs appear here while they run and stay as a record afterwards. History is kept in memory, so it resets when LocalAI restarts.", + "browseModels": "Browse models", + "viewInModels": "View in Models", + "viewInBackends": "View in Backends", + "rowInstalled": "installed in {{duration}}", + "rowFailed": "failed: {{error}}", + "rowCancelled": "cancelled", + "rowRemoved": "removed", + "retryFailed": "Retry failed: {{message}}", + "filter": { + "all": "All", + "models": "Models", + "backends": "Backends", + "cluster": "Cluster" + }, + "summaryRunning_one": "{{count}} operation running.", + "summaryRunning_other": "{{count}} operations running.", + "summaryFailed_one": "{{count}} operation needs attention.", + "summaryFailed_other": "{{count}} operations need attention.", + "summaryQuiet_one": "Nothing running. {{count}} operation since startup.", + "summaryQuiet_other": "Nothing running. {{count}} operations since startup.", + "summaryIdle": "Nothing running.", + "emptyFiltered": "No operations match this filter.", + "showAll": "Show all", + "rowInstalledPlain": "installed" + }, "manage": { "title": "Sistem", "subtitle": "Kelola model dan backend yang terinstal" diff --git a/core/http/react-ui/public/locales/id/nav.json b/core/http/react-ui/public/locales/id/nav.json index c13c197d9..5811018e2 100644 --- a/core/http/react-ui/public/locales/id/nav.json +++ b/core/http/react-ui/public/locales/id/nav.json @@ -23,7 +23,8 @@ "cluster": "Kluster", "observability": "Observabilitas", "access": "Akses", - "system": "Sistem" + "system": "Sistem", + "activity": "Activity" }, "items": { "home": "Beranda", @@ -55,7 +56,8 @@ "swarm": "Swarm", "system": "Sistem", "settings": "Pengaturan", - "api": "API" + "api": "API", + "activity": "Aktivitas" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/it/admin.json b/core/http/react-ui/public/locales/it/admin.json index 7d6a47523..79b185638 100644 --- a/core/http/react-ui/public/locales/it/admin.json +++ b/core/http/react-ui/public/locales/it/admin.json @@ -1,4 +1,84 @@ { + "activity": { + "title": "Activity", + "supporting": "Installs, downloads and removals on this instance.", + "hide": "Hide", + "moveToHistory": "Move to history", + "moreCount": "{{count}} more", + "waitingForInstaller": "waiting for the installer", + "progressLabel": "Progress for {{name}}", + "toNode": "to {{node}}", + "nodesDone": "{{done}} of {{total}} nodes done", + "timeLeft": "{{value}} left", + "cancel": "Cancel", + "cancelLabel": "Cancel {{name}}", + "retry": "Retry", + "retryLabel": "Retry {{name}}", + "nodeCount": "{{count}} nodes", + "showNodes": "Show {{count}} nodes", + "hideNodes": "Hide per-node detail", + "node": { + "done": "Done", + "failed": "Failed", + "queued": "Queued", + "workerBusy": "Worker busy", + "downloading": "Downloading" + }, + "kind": { + "model": "model", + "backend": "backend" + }, + "verb": { + "installing": "Installing {{kind}}", + "installed": "Installed {{kind}}", + "removed": "Removed {{kind}}", + "staged": "Staged model", + "failed": "Couldn't install {{kind}}", + "queued": "Queued", + "staging": "Staging model", + "removing": "Removing {{kind}}", + "failedRemoval": "Couldn't remove {{kind}}", + "failedStaging": "Couldn't stage model" + }, + "phase": { + "resolving": "Resolving files", + "downloading": "Downloading", + "verifying": "Verifying", + "committing": "Finalizing", + "persisting": "Saving configuration" + }, + "clearHistory": "Clear history", + "inProgress": "In progress", + "needsAttention": "Needs attention", + "record": "Record", + "historyNote": "Keeps the last 50 operations. History is in memory and resets when LocalAI restarts.", + "emptyTitle": "No operations since startup", + "emptyBody": "Model and backend installs appear here while they run and stay as a record afterwards. History is kept in memory, so it resets when LocalAI restarts.", + "browseModels": "Browse models", + "viewInModels": "View in Models", + "viewInBackends": "View in Backends", + "rowInstalled": "installed in {{duration}}", + "rowFailed": "failed: {{error}}", + "rowCancelled": "cancelled", + "rowRemoved": "removed", + "retryFailed": "Retry failed: {{message}}", + "filter": { + "all": "All", + "models": "Models", + "backends": "Backends", + "cluster": "Cluster" + }, + "summaryRunning_one": "{{count}} operation running.", + "summaryRunning_other": "{{count}} operations running.", + "summaryFailed_one": "{{count}} operation needs attention.", + "summaryFailed_other": "{{count}} operations need attention.", + "summaryQuiet_one": "Nothing running. {{count}} operation since startup.", + "summaryQuiet_other": "Nothing running. {{count}} operations since startup.", + "summaryIdle": "Nothing running.", + "emptyFiltered": "No operations match this filter.", + "showAll": "Show all", + "rowInstalledPlain": "installed" + }, "manage": { "title": "Sistema", "subtitle": "Gestisci modelli e backend installati" diff --git a/core/http/react-ui/public/locales/it/nav.json b/core/http/react-ui/public/locales/it/nav.json index c54171f39..0c79e29ce 100644 --- a/core/http/react-ui/public/locales/it/nav.json +++ b/core/http/react-ui/public/locales/it/nav.json @@ -23,7 +23,8 @@ "cluster": "Cluster", "observability": "Observability", "access": "Access", - "system": "System" + "system": "System", + "activity": "Activity" }, "items": { "home": "Home", @@ -55,7 +56,8 @@ "system": "Sistema", "settings": "Impostazioni", "api": "API", - "middleware": "Middleware" + "middleware": "Middleware", + "activity": "Attività" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/ko/admin.json b/core/http/react-ui/public/locales/ko/admin.json index 3a21b7c26..b727ba4c4 100644 --- a/core/http/react-ui/public/locales/ko/admin.json +++ b/core/http/react-ui/public/locales/ko/admin.json @@ -1,4 +1,84 @@ { + "activity": { + "title": "Activity", + "supporting": "Installs, downloads and removals on this instance.", + "hide": "Hide", + "moveToHistory": "Move to history", + "moreCount": "{{count}} more", + "waitingForInstaller": "waiting for the installer", + "progressLabel": "Progress for {{name}}", + "toNode": "to {{node}}", + "nodesDone": "{{done}} of {{total}} nodes done", + "timeLeft": "{{value}} left", + "cancel": "Cancel", + "cancelLabel": "Cancel {{name}}", + "retry": "Retry", + "retryLabel": "Retry {{name}}", + "nodeCount": "{{count}} nodes", + "showNodes": "Show {{count}} nodes", + "hideNodes": "Hide per-node detail", + "node": { + "done": "Done", + "failed": "Failed", + "queued": "Queued", + "workerBusy": "Worker busy", + "downloading": "Downloading" + }, + "kind": { + "model": "model", + "backend": "backend" + }, + "verb": { + "installing": "Installing {{kind}}", + "installed": "Installed {{kind}}", + "removed": "Removed {{kind}}", + "staged": "Staged model", + "failed": "Couldn't install {{kind}}", + "queued": "Queued", + "staging": "Staging model", + "removing": "Removing {{kind}}", + "failedRemoval": "Couldn't remove {{kind}}", + "failedStaging": "Couldn't stage model" + }, + "phase": { + "resolving": "Resolving files", + "downloading": "Downloading", + "verifying": "Verifying", + "committing": "Finalizing", + "persisting": "Saving configuration" + }, + "clearHistory": "Clear history", + "inProgress": "In progress", + "needsAttention": "Needs attention", + "record": "Record", + "historyNote": "Keeps the last 50 operations. History is in memory and resets when LocalAI restarts.", + "emptyTitle": "No operations since startup", + "emptyBody": "Model and backend installs appear here while they run and stay as a record afterwards. History is kept in memory, so it resets when LocalAI restarts.", + "browseModels": "Browse models", + "viewInModels": "View in Models", + "viewInBackends": "View in Backends", + "rowInstalled": "installed in {{duration}}", + "rowFailed": "failed: {{error}}", + "rowCancelled": "cancelled", + "rowRemoved": "removed", + "retryFailed": "Retry failed: {{message}}", + "filter": { + "all": "All", + "models": "Models", + "backends": "Backends", + "cluster": "Cluster" + }, + "summaryRunning_one": "{{count}} operation running.", + "summaryRunning_other": "{{count}} operations running.", + "summaryFailed_one": "{{count}} operation needs attention.", + "summaryFailed_other": "{{count}} operations need attention.", + "summaryQuiet_one": "Nothing running. {{count}} operation since startup.", + "summaryQuiet_other": "Nothing running. {{count}} operations since startup.", + "summaryIdle": "Nothing running.", + "emptyFiltered": "No operations match this filter.", + "showAll": "Show all", + "rowInstalledPlain": "installed" + }, "manage": { "title": "시스템", "subtitle": "설치된 모델과 백엔드를 관리합니다" diff --git a/core/http/react-ui/public/locales/ko/nav.json b/core/http/react-ui/public/locales/ko/nav.json index dbd2016cc..df0825960 100644 --- a/core/http/react-ui/public/locales/ko/nav.json +++ b/core/http/react-ui/public/locales/ko/nav.json @@ -23,7 +23,8 @@ "cluster": "Cluster", "observability": "Observability", "access": "Access", - "system": "System" + "system": "System", + "activity": "Activity" }, "items": { "home": "홈", @@ -55,7 +56,8 @@ "swarm": "Swarm", "system": "시스템", "settings": "설정", - "api": "API" + "api": "API", + "activity": "활동" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/zh-CN/admin.json b/core/http/react-ui/public/locales/zh-CN/admin.json index 38a597ee0..43b6b10f7 100644 --- a/core/http/react-ui/public/locales/zh-CN/admin.json +++ b/core/http/react-ui/public/locales/zh-CN/admin.json @@ -1,4 +1,84 @@ { + "activity": { + "title": "Activity", + "supporting": "Installs, downloads and removals on this instance.", + "hide": "Hide", + "moveToHistory": "Move to history", + "moreCount": "{{count}} more", + "waitingForInstaller": "waiting for the installer", + "progressLabel": "Progress for {{name}}", + "toNode": "to {{node}}", + "nodesDone": "{{done}} of {{total}} nodes done", + "timeLeft": "{{value}} left", + "cancel": "Cancel", + "cancelLabel": "Cancel {{name}}", + "retry": "Retry", + "retryLabel": "Retry {{name}}", + "nodeCount": "{{count}} nodes", + "showNodes": "Show {{count}} nodes", + "hideNodes": "Hide per-node detail", + "node": { + "done": "Done", + "failed": "Failed", + "queued": "Queued", + "workerBusy": "Worker busy", + "downloading": "Downloading" + }, + "kind": { + "model": "model", + "backend": "backend" + }, + "verb": { + "installing": "Installing {{kind}}", + "installed": "Installed {{kind}}", + "removed": "Removed {{kind}}", + "staged": "Staged model", + "failed": "Couldn't install {{kind}}", + "queued": "Queued", + "staging": "Staging model", + "removing": "Removing {{kind}}", + "failedRemoval": "Couldn't remove {{kind}}", + "failedStaging": "Couldn't stage model" + }, + "phase": { + "resolving": "Resolving files", + "downloading": "Downloading", + "verifying": "Verifying", + "committing": "Finalizing", + "persisting": "Saving configuration" + }, + "clearHistory": "Clear history", + "inProgress": "In progress", + "needsAttention": "Needs attention", + "record": "Record", + "historyNote": "Keeps the last 50 operations. History is in memory and resets when LocalAI restarts.", + "emptyTitle": "No operations since startup", + "emptyBody": "Model and backend installs appear here while they run and stay as a record afterwards. History is kept in memory, so it resets when LocalAI restarts.", + "browseModels": "Browse models", + "viewInModels": "View in Models", + "viewInBackends": "View in Backends", + "rowInstalled": "installed in {{duration}}", + "rowFailed": "failed: {{error}}", + "rowCancelled": "cancelled", + "rowRemoved": "removed", + "retryFailed": "Retry failed: {{message}}", + "filter": { + "all": "All", + "models": "Models", + "backends": "Backends", + "cluster": "Cluster" + }, + "summaryRunning_one": "{{count}} operation running.", + "summaryRunning_other": "{{count}} operations running.", + "summaryFailed_one": "{{count}} operation needs attention.", + "summaryFailed_other": "{{count}} operations need attention.", + "summaryQuiet_one": "Nothing running. {{count}} operation since startup.", + "summaryQuiet_other": "Nothing running. {{count}} operations since startup.", + "summaryIdle": "Nothing running.", + "emptyFiltered": "No operations match this filter.", + "showAll": "Show all", + "rowInstalledPlain": "installed" + }, "manage": { "title": "系统", "subtitle": "管理已安装的模型和后端" diff --git a/core/http/react-ui/public/locales/zh-CN/nav.json b/core/http/react-ui/public/locales/zh-CN/nav.json index 730791ddd..39d7b4eaa 100644 --- a/core/http/react-ui/public/locales/zh-CN/nav.json +++ b/core/http/react-ui/public/locales/zh-CN/nav.json @@ -23,7 +23,8 @@ "cluster": "Cluster", "observability": "Observability", "access": "Access", - "system": "System" + "system": "System", + "activity": "Activity" }, "items": { "home": "首页", @@ -55,7 +56,8 @@ "system": "系统", "settings": "设置", "api": "API", - "middleware": "Middleware" + "middleware": "Middleware", + "activity": "活动" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index a973b1294..68a7e4d5a 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -13,6 +13,11 @@ min-height: 100dvh; display: flex; flex-direction: column; + /* A flex item's automatic minimum is its content's minimum, so without this + any single wide descendant (a long install error in the operations strip, + a wide table on a phone) drags the whole column past the viewport and + every page gets a horizontal scrollbar. */ + min-width: 0; transition: margin-left var(--duration-normal) var(--ease-default); } @@ -663,41 +668,151 @@ to { transform: rotate(360deg); } } -/* Operations bar */ -.operations-bar { - background: var(--color-bg-secondary); - border-bottom: 1px solid var(--color-border-subtle); - padding: var(--spacing-xs) var(--spacing-md); -} - -.operation-text { - font-family: var(--font-mono); -} -.operation-progress { - font-variant-numeric: tabular-nums; -} - -.operation-item { - display: flex; - align-items: center; - gap: var(--spacing-md); - padding: var(--spacing-xs) 0; - flex-wrap: wrap; -} - -.operation-info { +/* Operations strip: always exactly one line. Anything that does not fit here + belongs on /app/activity. */ +.operations-strip { display: flex; align-items: center; gap: var(--spacing-sm); - flex: 2 1 0; + min-height: 40px; + padding: var(--spacing-xs) var(--spacing-md); + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border-subtle); + border-left: 2px solid var(--color-primary); + font-size: 0.8125rem; + /* An install error is arbitrarily long text. This clips it; the shrinking + is done by min-width on .main-content above and on __detail below. */ + overflow: hidden; +} +.operations-strip--error { border-left-color: var(--color-error); background: var(--color-error-light); } +.operations-strip--queued { border-left-color: var(--color-text-disabled); } +.operations-strip--staging { border-left-color: var(--color-info); } +.operations-strip--removing { border-left-color: var(--color-warning); } +.operations-strip--done { border-left-color: var(--color-success); } + +.operations-strip__icon { flex: none; font-size: 0.8125rem; } +.operations-strip--error .operations-strip__icon { color: var(--color-error); } +.operations-strip--queued .operations-strip__icon { color: var(--color-text-muted); } +.operations-strip--staging .operations-strip__icon { color: var(--color-info); } +.operations-strip--removing .operations-strip__icon { color: var(--color-warning); } +.operations-strip--done .operations-strip__icon { color: var(--color-success); } + +.operations-strip__spinner { + width: 13px; + height: 13px; + flex: none; + border-radius: 50%; + border: 2px solid var(--color-primary-light); + border-top-color: var(--color-primary); + animation: operationsStripSpin 0.9s linear infinite; +} +@keyframes operationsStripSpin { to { transform: rotate(360deg); } } + +.operations-strip__verb { flex: none; color: var(--color-text-secondary); white-space: nowrap; } +.operations-strip__name { + font-family: var(--font-mono); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* overflow: hidden zeroes the automatic minimum size, so without a floor the + name loses the shrink contest to a long error and the identity of the + thing that broke is the first casualty. */ + min-width: 12ch; +} +.operations-strip__sep { flex: none; color: var(--color-border-strong); } +/* min-width and overflow are what let these shrink: a nowrap flex item's + automatic minimum size is otherwise its full content width. */ +.operations-strip__detail, +.operations-strip__bytes { + flex: 0 1 auto; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-muted); + font-size: 0.75rem; + white-space: nowrap; +} +.operations-strip__bytes { font-variant-numeric: tabular-nums; } +.operations-strip__spacer { flex: 1 1 auto; min-width: var(--spacing-xs); } +.operations-strip__pct { + flex: none; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-primary); +} +.operations-strip__track { + flex: 0 0 132px; + height: 3px; + border-radius: var(--radius-full); + background: var(--color-surface-sunken); + overflow: hidden; +} +.operations-strip__fill { + display: block; + height: 100%; + border-radius: var(--radius-full); + background: var(--color-primary); + transition: width var(--duration-slow) var(--ease-spring); +} +.operations-strip--staging .operations-strip__fill { background: var(--color-info); } + +.operations-strip__more { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + min-height: 28px; + padding: 0 var(--spacing-sm); + border-radius: var(--radius-full); + border: 1px solid var(--color-primary-border); + background: var(--color-primary-light); + color: var(--color-primary); + font-size: 0.6875rem; + font-weight: 600; + white-space: nowrap; + text-decoration: none; +} +.operations-strip__more:hover { background: var(--color-primary); color: var(--color-primary-text); } +.operations-strip__more--neutral { + background: transparent; + border-color: var(--color-border-default); + color: var(--color-text-secondary); } -.operation-info > .operation-text { - flex: 1 1 auto; - min-width: 0; +.operations-strip__hide { + flex: none; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; +} +.operations-strip__hide:hover { background: var(--color-bg-hover); color: var(--color-text-primary); } + +/* Narrow screens drop the prose, never the name, the percentage or the + counter. */ +@media (max-width: 640px) { + .operations-strip__verb, + .operations-strip__detail, + .operations-strip__bytes, + .operations-strip__sep, + .operations-strip__track { display: none; } + .operations-strip__name { max-width: 45vw; } } +@media (prefers-reduced-motion: reduce) { + .operations-strip__spinner { animation: none; } + .operations-strip__fill { transition: none; } +} + +/* Row-level install indicator, used by the Models and Backends tables. */ .operation-spinner { width: 16px; height: 16px; @@ -710,19 +825,6 @@ display: inline-block; } -.operation-text { - font-size: 0.8125rem; - color: var(--color-text-secondary); - overflow: hidden; - text-overflow: ellipsis; -} - -.operation-progress { - font-size: 0.75rem; - color: var(--color-primary); - font-weight: 500; -} - .operation-bar-container { flex: 0 1 160px; min-width: 80px; @@ -760,38 +862,7 @@ white-space: nowrap; } -.operation-cancel { - flex-shrink: 0; - background: none; - border: none; - color: var(--color-text-muted); - cursor: pointer; - padding: 4px 6px; - font-size: 0.875rem; -} -.operation-cancel:hover { - color: var(--color-error); -} - -/* Operations bar: per-node breakdown (multi-worker installs) */ -.operation-expand { - background: none; - border: none; - color: var(--color-text-muted); - cursor: pointer; - padding: 0 var(--spacing-xs); - font-size: var(--text-xs); - display: inline-flex; - align-items: center; - gap: 0.25rem; -} -.operation-expand:hover { - color: var(--color-text-primary); -} -.operation-expand-label { - font-size: var(--text-xs); -} - +/* Per-node breakdown of a multi-worker install. */ .operation-nodes-list { list-style: none; margin: var(--spacing-xs) 0 0; @@ -5563,20 +5634,6 @@ button.collapsible-header:focus-visible { border-right: 0; margin-inline: calc(-1 * var(--spacing-md)); } - - /* Operations toasts: scroll horizontally instead of wrapping */ - .operations-bar { - overflow-x: auto; - flex-wrap: nowrap; - -webkit-overflow-scrolling: touch; - } - .operation-item { flex-shrink: 0; } - .operation-text { - max-width: 60vw; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } } /* Reduced motion — disable non-essential transitions for users who @@ -5584,7 +5641,7 @@ button.collapsible-header:focus-visible { @media (prefers-reduced-motion: reduce) { .sidebar, .page-transition, - .operations-bar, + .operations-strip, .page, .main-content { transition: none !important; @@ -9645,3 +9702,224 @@ button.collapsible-header:focus-visible { .variant-row__info, .variant-row__action { transition: none; } } + +/* Live operation card on /app/activity. Status is carried by the icon and the + tag rather than a coloured rail, so a page of cards does not read as stripes. */ +.operation-card { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + overflow: hidden; +} +.operation-card--error { border-color: var(--color-error-border); background: var(--color-error-light); } +.operation-card__main { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); } +.operation-card__icon { flex: none; } +.operation-card__icon--error { color: var(--color-error); } +.operation-card__icon--staging { color: var(--color-info); } +.operation-card__icon--removing { color: var(--color-warning); } +.operation-card__spinner { + width: 14px; + height: 14px; + flex: none; + border-radius: 50%; + border: 2px solid var(--color-primary-light); + border-top-color: var(--color-primary); + animation: operationsStripSpin 0.9s linear infinite; +} +.operation-card__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.operation-card__title { display: flex; align-items: center; gap: var(--spacing-xs); flex-wrap: wrap; } +.operation-card__name { font-family: var(--font-mono); font-weight: 500; } +.operation-card__tag { + font-size: 0.625rem; + letter-spacing: 0.07em; + text-transform: uppercase; + font-weight: 600; + padding: 1px var(--spacing-xs); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-default); + color: var(--color-text-muted); +} +.operation-card__tag--model { color: var(--color-primary); border-color: var(--color-primary-border); background: var(--color-primary-light); } +.operation-card__tag--backend { color: var(--color-info); border-color: var(--color-info-border); background: var(--color-info-light); } +.operation-card__tag--cluster { color: var(--color-warning); border-color: var(--color-warning-border); background: var(--color-warning-light); } +.operation-card__sub { display: flex; align-items: center; gap: var(--spacing-xs); flex-wrap: wrap; font-size: 0.75rem; color: var(--color-text-muted); } +.operation-card__bytes { font-variant-numeric: tabular-nums; } +/* The legacy installer message embeds an absolute file path, so it is both long + and a single unbreakable token. Left unclamped it wraps to three lines and + becomes the largest thing on the card, which is how it looked against a real + download. One line, ellipsised, full text in the title. */ +.operation-card__message { + min-width: 0; + flex: 1 1 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.operation-card__verb { color: var(--color-text-secondary); } +/* A Go error is arbitrarily long and often multi-line. Clamped so it cannot + push the rest of the card off screen; the full text is in the title. */ +.operation-card__error { + color: var(--color-error); + font-family: var(--font-mono); + /* A flex item defaults to min-width:auto, so one unbroken token (a URL + inside a Go error) would refuse to shrink and push the sub row past the + card. The vertical clamp below cannot help with that. */ + min-width: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; +} +.operation-card__track { height: 4px; border-radius: var(--radius-full); background: var(--color-surface-sunken); overflow: hidden; } +.operation-card__fill { display: block; height: 100%; border-radius: var(--radius-full); background: var(--color-primary); transition: width var(--duration-slow) var(--ease-spring); } +.operation-card__actions { display: flex; align-items: center; gap: var(--spacing-xs); flex: none; } +.operation-card__pct { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 600; color: var(--color-primary); } +.operation-card__hide { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; +} +.operation-card__hide:hover { background: var(--color-bg-hover); color: var(--color-text-primary); } +.operation-card__nodes-toggle { + display: flex; + align-items: center; + gap: var(--spacing-xs); + width: 100%; + min-height: 28px; + padding: var(--spacing-xs) var(--spacing-sm); + border: 0; + border-top: 1px solid var(--color-border-subtle); + background: transparent; + color: var(--color-text-muted); + font-size: 0.6875rem; + cursor: pointer; +} +.operation-card__nodes-toggle:hover { color: var(--color-text-secondary); background: var(--color-bg-hover); } + +/* The node list carries its own inset here. It was written for the old bar, + whose parent supplied one, and the card only pads its main row. */ +.operation-card .operation-nodes-list { padding: 0 var(--spacing-sm) var(--spacing-xs); } +/* One rule between the card body and the node block, not two: when the + disclosure is there, it is already the separator. */ +.operation-card__nodes-toggle + .operation-nodes-list { margin-top: 0; border-top: 0; } +.operation-card .operation-node-error { + max-width: 40ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + .operation-card__spinner { animation: none; } + .operation-card__fill { transition: none; } + .operation-card .operation-node-bar { transition: none; } +} + +/* ── Activity page ────────────────────────────────────────────────────────── */ +.activity-page { display: flex; flex-direction: column; gap: var(--spacing-md); } +.activity-filters { display: flex; gap: var(--spacing-xs); flex-wrap: wrap; } +.activity-chip { + min-height: 28px; + padding: 0 var(--spacing-sm); + border-radius: var(--radius-full); + border: 1px solid var(--color-border-default); + background: transparent; + color: var(--color-text-muted); + font-size: 0.75rem; + cursor: pointer; +} +.activity-chip[aria-pressed="true"] { + background: var(--color-primary-light); + border-color: var(--color-primary-border); + color: var(--color-primary); + font-weight: 500; +} +.activity-section { display: flex; flex-direction: column; gap: var(--spacing-sm); } +.activity-section__title { + display: flex; + align-items: baseline; + gap: var(--spacing-xs); + margin: 0; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-secondary); +} +.activity-section__count { font-family: var(--font-mono); color: var(--color-text-muted); font-weight: 400; } +.activity-rows { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + overflow: hidden; +} +.activity-row { + display: grid; + grid-template-columns: 16px 1fr auto auto; + gap: var(--spacing-sm); + align-items: center; + min-height: 38px; + padding: var(--spacing-xs) var(--spacing-sm); + border-bottom: 1px solid var(--color-border-subtle); + font-size: 0.8125rem; +} +.activity-row:last-child { border-bottom: 0; } +.activity-row:hover { background: var(--color-bg-hover); } +.activity-row__icon--completed { color: var(--color-success); } +.activity-row__icon--failed { color: var(--color-error); } +.activity-row__icon--cancelled { color: var(--color-warning); } +.activity-row__name { font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-row__name small { font-family: var(--font-sans); color: var(--color-text-muted); margin-left: var(--spacing-xs); } +.activity-row__when { color: var(--color-text-muted); font-size: 0.75rem; font-variant-numeric: tabular-nums; } +.activity-row__action { color: var(--color-primary); font-size: 0.75rem; text-decoration: none; white-space: nowrap; } +.activity-row__action:hover { text-decoration: underline; } +.activity-note { font-size: 0.75rem; color: var(--color-text-muted); margin: 0; } +.activity-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xl) var(--spacing-md); + text-align: center; +} +.activity-empty__icon { font-size: 1.5rem; color: var(--color-text-disabled); margin-bottom: var(--spacing-xs); } +.activity-empty__title { margin: 0; font-weight: 500; color: var(--color-text-secondary); } +.activity-empty__body { margin: 0; max-width: 46ch; font-size: 0.8125rem; color: var(--color-text-muted); } +/* A chip matching nothing is a smaller event than an empty instance, and gets + less of the page so the chip row above stays the obvious thing to change. */ +.activity-empty--filtered { padding: var(--spacing-lg) var(--spacing-md); } + +.nav-badge { + min-width: 18px; + height: 18px; + padding: 0 var(--spacing-xs); + border-radius: var(--radius-full); + background: var(--color-primary); + color: var(--color-primary-text); + font-family: var(--font-mono); + font-size: 0.625rem; + font-weight: 700; + line-height: 18px; + text-align: center; + font-variant-numeric: tabular-nums; +} +.nav-badge--error { background: var(--color-error); color: var(--color-bg-secondary); } +/* Collapsed, the label is gone and the item centres its icon: an inline badge + would shove that icon off the rail's axis. Pinning it to the corner keeps the + count visible without moving anything else. */ +.sidebar.collapsed .nav-badge { + position: absolute; + top: 2px; + right: 4px; + min-width: 16px; + height: 16px; + line-height: 16px; + font-size: 0.5625rem; +} diff --git a/core/http/react-ui/src/components/OperationCard.jsx b/core/http/react-ui/src/components/OperationCard.jsx new file mode 100644 index 000000000..fa67ce54e --- /dev/null +++ b/core/http/react-ui/src/components/OperationCard.jsx @@ -0,0 +1,233 @@ +import { useId, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { formatBytes } from '../utils/format' + +const phaseKeys = { + resolving: 'activity.phase.resolving', + downloading: 'activity.phase.downloading', + verifying: 'activity.phase.verifying', + committing: 'activity.phase.committing', + persisting: 'activity.phase.persisting', +} + +const nodeStatusKeys = { + success: 'activity.node.done', + error: 'activity.node.failed', + queued: 'activity.node.queued', + running_on_worker: 'activity.node.workerBusy', + downloading: 'activity.node.downloading', +} + +// etaSeconds is derived by OperationsContext from the byte delta between +// polls. It is absent until two samples exist, and absent for every operation +// when it is absent for any byte-tracked one. +function formatEta(seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) return '' + if (seconds < 60) return `${seconds}s` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes} min` + return `${Math.floor(minutes / 60)}h ${minutes % 60}m` +} + +export default function OperationCard({ operation, onCancel, onDismiss, onRetry }) { + const { t } = useTranslation('admin') + const nodes = Array.isArray(operation.nodes) ? operation.nodes : [] + // Holds only what the user chose. The default has to stay a live + // expression: an operation appears in /api/operations as soon as it is + // admitted, but its nodes are filled in later, when the fan-out starts + // reporting. State seeded at mount would latch on the empty list. + const [nodesOpenOverride, setNodesOpenOverride] = useState(null) + const listId = useId() + + const failed = Boolean(operation.error) + const name = operation.name || operation.id + const kind = operation.isBackend ? t('activity.kind.backend') : t('activity.kind.model') + + // Same chain as the one-line strip, so the two never describe one job + // differently. Without it a removal and an install render identically: a + // deletion has no phase, no bytes and no nodes to tell them apart. + let icon + let verb + if (failed) { + icon =