From 0f7186f21432eceb50fcc0870087665853fdb242 Mon Sep 17 00:00:00 2001 From: "mudler's LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:33:48 +0200 Subject: [PATCH] feat(ui): replace the stacked operations bar with a one-line strip and an Activity page (#11163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): record finished gallery operations in a bounded history ring The operations panel drops an operation the moment it succeeds, so a user who steps away cannot tell whether an install finished, failed or was never started. OpCache now keeps the last 50 terminal operations, recorded from the point where an op leaves the cache. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * test(ui): pin the history ring's dedupe, outcome order and start stamp Review of the history ring found four gaps. The dedupe guard and the bounded seen set were unreachable through the exported API and so had no coverage; an in-package spec file now drives opHistory directly. The outcome switch claimed an ordering was load bearing that nothing pinned, so an errored op that never reached Processed now has a spec. Two behaviour fixes come with it. StartedAt was the zero time for ops recovered from the store or replicated from a peer, since neither path stamps a start time, which would have rendered as a two-millennia duration; it now falls back to the finish time. Reusing a cache key with a fresh job ID orphaned the previous stamp, so Set and SetBackend now drop it. The comment on the outcome switch described a state the code cannot be in: CancelOperation sets Cancelled and Processed synchronously before the handler removes the entry, so status.Cancelled already covers the cancel endpoint. The !Processed clause stays for the dismiss endpoint firing on an in-flight op, and the comments now say so. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * feat(ui): record operations that end on a peer replica The NATS end event is the only signal a replica gets for an install another replica ran. Record from applyEnd too, deduped by job ID so the originating replica does not record its own broadcast twice. Three start-stamp defects in the same path go with it. applyEnd now drops the stamp unconditionally, since recordTerminal only cleans up on the path where it found a cache key and an end event can overtake the local Set. applyStart drops the stamp of the job whose cache key it replaces, which a peer-driven retry previously stranded. And recordTerminal reads the stamp once instead of testing Exists and then reading, so a concurrent record for the same job can no longer delete the stamp between the two and let the zero time overwrite the finish-time fallback, which the Activity page would render as a two-millennia run. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): do not guess the outcome of a peer operation with no local status A replica that restarts mid-operation hydrates its OpCache keys from PostgreSQL, but gallery statuses are in-memory only and come back empty. The end broadcast then landed on recordTerminal's nil-status branch, which reads a missing status as queued-and-removed and filed a successful install as cancelled. That reading is right locally and wrong on the peer path, where a missing status means the outcome was never held here. recordTerminal now takes the source of the terminal event and records nothing when the peer path finds no status, restoring what the replica did before the end event started recording. The local path is unchanged. Also move the ApplyEndForTest seam to the conventional export_test.go, and stop the dedupe spec from claiming to guard the ring's seen set: the local delete removes the status keys, so the broadcast that follows returns before reaching it. An in-package spec that calls recordTerminal twice does the pinning. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * feat(api): add GET and DELETE /api/operations/history Admin gated like the rest of the operations API. The live /api/operations payload is unchanged so the one second poll stays small. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * feat(ui): expose operation history through OperationsContext Fetched on demand and when the live list shrinks, never on the one second poll interval. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): detect operation departure by identity and ignore committing ops in the ETA gate Refetching history on a shrinking live count missed a completion that coincided with a start, which is the common case during a batch install. Track the live job IDs instead, so any departure triggers the refetch regardless of how the count moved. An operation that has finished downloading stays live at currentBytes == totalBytes for the whole commit and install phase and can never produce an estimate, so counting it in the all-or-nothing gate blanked every other operation's time remaining for as long as it lasted. Only operations still moving bytes get a vote. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): let only downloading operations gate the time remaining estimate Verifying pins an operation flat below its total for the whole sha256 pass: the AfterDownload hook reports completedBytes plus the finished file against a total summed over every file, then hashes synchronously without emitting progress. Files download sequentially, so a 15 shard model enters that window 14 times, and a byte comparison cannot see it because the counter is genuinely below the total throughout. Gating on phase closes resolving, verifying, committing and persisting in one predicate, so a quiet neighbour no longer blanks every other operation's estimate for minutes at a time. The byte clauses stay: a producer can report downloading with bytes already at the total. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * feat(ui): collapse the operations bar to a single line Four concurrent installs used to take four rows above every page. The strip now shows one operation, failure first, with a counter linking to Activity. The close button hides the strip and no longer cancels an install. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): keep the operations strip from widening the page and from muting a failure A long install error made the strip report a 1600px minimum width, which sized main-content to fit and gave every page under it a horizontal scrollbar. Inline-size containment plus shrinkable detail and bytes cells keep it inside the viewport. Hiding is no longer able to swallow the hidden job's own failure, a completed removal or staging says so instead of claiming an install, a cancelling operation renders as cancelling, and the live region no longer covers the per-second percentage. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): shrink main-content instead of containing the strip, and expose progress min-width on .main-content is what actually lets a long install error shrink, and unlike inline-size containment it has no browser support floor and no latent collapse if the strip ever lands in a shrink-to-fit context. It matches what .app-layout-chat .main-content already does, and it clears pre-existing horizontal overflow on narrow viewports as a side effect. The progress track is now a labelled progressbar, so assistive tech can read the value on demand rather than losing it to the aria-hidden that stopped the live region re-announcing every poll. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * feat(ui): add the live operation card for the Activity page Carries the detail the one-line strip has to drop: phase, bytes, the per-node breakdown for cluster installs, and a labelled Cancel button. Cancelling is destructive, so it gets a labelled button rather than a glyph. A cancelling operation drops its progress bar and its time estimate, the same call the strip makes: a percentage still climbing under "Cancelling" reads as the cancel not having taken. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): give the operation card a verb, live node disclosure and its per-node detail The card carried no verb, so an install, a removal and a staging op rendered as spinner plus name plus kind tag and were indistinguishable. It now runs the same verb and icon chain as the one-line strip, which is what stops the page that is meant to carry more detail from carrying less. The auto-expand default was evaluated once at mount. An operation is listed as soon as it is admitted but its nodes are filled in only when the fan-out starts reporting, so a card mounted at creation latched on the empty list and stayed collapsed. The default is a live expression now, and state holds only an explicit choice. Also: an optional onRetry gates a Retry button, so the page can own the install reconstruction without the card ever showing a control with nothing behind it; the disclosure moved above the region it controls and gained aria-controls; the toggle is gated at more than one node so the count is never "1 nodes"; an unmapped node status is passed through instead of being relabelled "Queued"; error text is clamped with the full string in the title; and file_name plus the per-node progress bar are rendered again, reviving three CSS rules that had gone dead along with the detail they styled. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * feat(ui): add the Activity page Live operations, unacknowledged failures and the record of what finished, at /app/activity in the Operate console. Cancelling an install now lives here behind a labelled button rather than on the strip, and a failed install can be retried: the retry dismisses the failure first so it still reaches the record, then reissues the model, backend or node-scoped backend install. The sidebar Operate entry carries the operation count. The console rail is only rendered on an Operate route and can be collapsed, so a badge there could vanish while operations were still running. Two follow-ups from review fold in here: a failed removal or staging job no longer reports a failed install on either the card or the strip, and the card's error text can shrink so one unbroken token cannot widen the card. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): dismiss operations by job, and stop the Activity page contradicting itself Dismissing resolved the job by display id, but /api/operations strips the "node::" prefix before emitting, so a local install and a node-scoped install of one backend arrive as two jobs sharing one id. Dismissing by id retired whichever came first. That defeated the guarantee retry was built around: with the wrong job dismissed, the reinstall overwrote the acted-on failure's opcache entry in place, bypassing recordTerminal, while an unrelated failure vanished from Needs attention. dismissFailedOp, the card's dismiss control and the strip now all pass the jobID, which is what the endpoint takes. A filter matching nothing rendered the "nothing has ever run" empty state while the header counted the records the filter had hidden. The empty state is now gated on the All chip and a narrowed view gets its own message plus a way back; the header counts the instance rather than the chip, so selecting Backends no longer reports "Nothing running" over running model installs. Also: the summary drops a zero clause instead of rendering "0 needs attention" on the happy path and pluralises both counts; a record duration is floored at "< 1s" and rejected above a day, so a zero-value start stamp cannot render a span of millennia and a zero span cannot render "installed in" with nothing after it; a deletion cancelled mid-flight reports the cancellation rather than claiming it was removed; and the retry variant comment names the fix instead of calling the gap closed. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * docs: document the Activity page and the operations history endpoints Adds an Activity page under Operations covering the one-line operations strip, the /app/activity sections and filters, per-operation cancel, retry and dismiss, the in-memory 50-entry record, and the sidebar count. Documents GET and DELETE /api/operations/history, and fills the gap in the admin-only endpoint list, which also omitted the pre-existing POST /api/operations/:jobID/dismiss. Corrects the distributed-mode install-watching section: the per-node breakdown now lives on the Activity page rather than on the strip, which rolls a fan-out up into a single phrase. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * docs: correct nine details in the Activity page documentation The operations strip never renders a file name: its detail line is the error, the node roll-up, the target node, the phase or the queued note. Drops the stale clause in the distributed-mode section, where the per-node bullet is now the only place a file name is described. Scopes the phase vocabulary to artifact-backed gallery models, since a plain GGUF install emits no phase. Corrects the per-node list: the toggle exists for any fan-out of two or more workers and the four-node threshold only governs whether it starts open, while the N nodes tag needs more than one node. Notes that a cancelled operation can sit in the live section reading Cancelling, that cluster staging never reaches the record, and that Clear history appears only when the record has something in it. Names the operations response envelope, with a JSON example, so callers do not index a bare array, and stops describing the icon-only dismiss control as a labelled button. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * docs: drop the unreachable Cancelling state and scope the byte claims An operation can only report isCancelled while it is unprocessed, but every writer of Cancelled sets Processed in the same breath, on the peer path as much as the local one, and the cache evicts cancelled entries before the handler sees them. The state cannot reach the page, so the live section is described again as running or queued operations. Byte counts come from the artifact bridge alone, the same producer as the phase, so a plain GGUF install, a removal and a backend install report none. Scopes both to artifact-backed gallery models and leaves the verb, the name and the percentage as what every operation shows. A worker backend install reports its bytes through fields the operations payload does not carry, so the distributed section now describes the percentage and the node roll-up, with per-file counts pointed at the per-node detail. Also: staging jobs carry no error, so they never reach Needs attention and Retry never had a staging case to exclude; an install that involves workers is no longer called node-scoped, which this page uses for node-targeted installs; and the record timestamps carry nanoseconds. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * docs: state only the verb and the name as unconditional on the strip The percentage is as conditional as the bytes were: it renders only for a running operation that has reported progress, so a queued operation, a failed one and a removal never carry it. A removal in particular sits at progress zero for its whole visible life, since the delete path reports none and its completion is filtered out. Both the strip and the card paragraphs now lead with what always shows and list the rest as conditions. The Cluster chip matches on a node list that finished operations do not carry, so a fan-out install leaves the chip once it reaches the record. Scoped that claim to the live sections. Two more of the same shape, found by re-reading each clause alone: the strip also appears for a failure, which is not running, and the four-second hold only applies when nothing replaces the operation that just finished. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): stop reporting a cancelled install as installed, and make queued real Three defects that all trace to one root cause: `isCancelled: true` is unreachable from /api/operations. Every writer of Cancelled=true also sets Processed=true, the handler skips Processed && Cancelled, and OpCache.GetStatus evicts a cancelled op before the handler iterates it. Cancelling the last running operation put a green "Installed model X" on the strip for four seconds: the completion hold was guarded by `!previous.isCancelled`, which is dead. A cancellation deletes the operation server side, so the strip sees exactly what it sees on a completion, and nothing in the payload separates the two. The signal now comes from the side that issued the cancel: the operations context remembers the job IDs it cancelled (pruned after a minute) and the strip asks before it holds anything. A cancelled operation goes as soon as it stops; the record already reports it as cancelled. isQueued was set only when the gallery status was missing, but markQueued publishes a "queued" status at admission, so a queued op has a status for its whole queued life and the state was unreachable outside a microsecond window. Every operation waiting behind a running install rendered as "Installing model X" with a spinner. The queued phase is now the signal, via an exported PhaseQueued and a nil-safe OpStatus.IsQueued() next to the writer. With those two fixed, the Cancelling state has no way to be entered: cancelling is instantaneous from the API's point of view. Its branches, CSS, locale key and the isCancelled field itself are removed rather than left for a future reader to assume they work. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(ui): keep a removal a removal, and say what an install is doing OpStatus.Deletion was set once, at admission, and lost on the next status write: UpdateStatus replaces the whole status and only carried Nodes forward. Every later writer (the worker's first write, the progress ticks, the failure path) leaves the field at its zero value, so the flag survived only the queued window, and both surfaces test isQueued first. The reachable consequence is that a failed removal reported itself as a failed install, which is exactly the shape the Activity page offers Retry for, and Retry installs: pressing it on a removal that failed re-downloaded the model. A running delete also rendered as "Installing model X" with a spinner, and a successful one as "Installed model X". Carry Deletion forward the way Nodes already is. A job is a delete or an install for its whole life; an unset flag means "no new information", not "this is an install". Pinned by Go specs on both the service and /api/operations: the existing Playwright specs were green only because they stubbed a payload the server could not emit. Also restore the operation's own status message on the Activity card. Phases and byte counters exist only on the managed-artifact path, so a legacy files: gallery model and every backend install rendered a sub-row with nothing in it but the verb. The strip stays terse on purpose. And give the strip's name a min-width floor: overflow: hidden zeroes its automatic minimum, so a long error squeezed the name down to "mod…" and the identity of the thing that broke was the first thing lost. primaryOperation is made module-private: its comment claimed the Activity page selected the same operation, but that page shows all of them, partitioned into failed and running, and never imported it. Assisted-by: Claude Code:Opus 5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * feat(activity): read the operations record from PostgreSQL The Activity page's record of finished installs and removals was a 50-entry in-memory ring per frontend replica. In distributed mode that is the wrong place for it: each replica keeps its own copy, a replica added by a scale-out or a rolling deploy starts empty and never backfills, and "Clear history" clears only the replica that served the request, so the record reappears on the next poll routed elsewhere. The data is already in gallery_operations. Read it from there. GalleryStore gains ListTerminal and ClearTerminal, sharing a lifted terminalStatuses set with CleanOld so there is one definition of "finished". ListTerminal orders by updated_at, when the operation reached its terminal status, because the record reports what finished and when. OpCache.History and ClearHistory dispatch on whether a store is wired, so the HTTP handlers and the OpRecord JSON shape are unchanged and the page needed no change. A failed store read falls back to the local ring rather than blanking the page, and ClearHistory empties the ring as well so a database blip cannot resurrect a record the admin just cleared. The name derivation in recordTerminal is lifted into operationDisplayName and used by both paths, so the ring and the store cannot name the same operation differently. Also fixes a pre-existing bug the store path made visible: the backend channel hardcoded op_type "backend_install" even for a removal, while the model channel derives model_install/model_delete from op.Delete. Both channels carry the same ManagementOp, whose Delete field the backend handler already branches on, so the backend channel now derives backend_delete the same way. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(activity): keep a cancelled operation cancelled, and report a failed clear Review follow-up on the store-backed Activity record. A cancelled install was recorded as a failure. The cancel handler persists "cancelled" synchronously, then the handler goroutine unwinds with the context error and Start hands that to updateError unconditionally, which overwrote the row with "failed: context canceled". The page rendered a cancelled install as a red failure card offering Retry, with a raw context error as the reason. Fixed in GalleryStore rather than in Start, because an operation finishes once and the paths that retire one are not mutually exclusive: UpdateStatus now refuses to rewrite a row that already reached a terminal status. That also pins updated_at to when the operation really finished, which is the key the record is ordered by, and Create's upsert now freezes the same columns so a worker dequeuing an operation the admin cancelled while it was queued cannot reopen it as pending. ClearHistory returned nothing, so a failed delete logged a warning while the handler still answered 200. The admin watched the record clear and come back on the next fetch with nothing said about why. It now returns the error, the DELETE handler answers 500, and the store is cleared before the local ring so a failure leaves the fallback record intact rather than faking an empty one. Hydrate is the only reader that decides from op_type whether an operation is a removal, and it tested for "model_delete" exactly, so the backend_delete added in the previous commit hydrated as an install: a replica restarting during a backend removal rendered "Installing backend X". Both discriminations now go through IsDeleteOpType/IsBackendOpType so a fifth op_type cannot silently read as an install in whichever consumer was missed. Also: the backend channel now persists Cancellable as !op.Delete, matching the model channel; IsBackend falls back to the op_type prefix, since is_backend_op is only written by UpsertCacheKey and the rows needing the name fallback were reporting backend operations as models; and an unrecognized terminal status is logged rather than quietly filed as a success, which is what the comment already claimed. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(activity): keep a reaped operation correctable by its real outcome The terminal-status freeze added in the previous commit was too wide. It froze "failed" alongside "completed" and "cancelled", and the stale reaper writes "failed" onto operations that are still going to run. The gallery worker is a single goroutine consuming both channels serially, so an operation queued behind a large download sits in "pending" with nothing bumping updated_at, and ReapStaleOperations gives up on it after 30 minutes. That used to be self-healing: the worker dequeued it, Create reset the row to "pending", and the operation reported its real outcome. With the freeze the row stayed "failed" forever while the install ran and succeeded underneath it: a red failure card offering Retry for a model that is installed, omitted from ListActive so no replica hydrates it, and no longer deduped cluster-wide by FindDuplicate. Freeze on ("completed", "cancelled") instead. That is all the cancelled-install fix ever needed, and it leaves a failure correctable by what actually happened. The set is separate from terminalStatuses, which ListTerminal, ClearTerminal and CleanOld all still want in full, because the two mean different things: a failure can be superseded by a real outcome, a completion or a cancellation is the real outcome. UpdateStatus now writes the error column unconditionally, so a corrected outcome drops the previous attempt's reason rather than being recorded as completed while still carrying "stale operation reaped" as its error. Also adds the route-level spec for the 500 branch of DELETE /api/operations/history, and trims a comment that credited the persisted cancellable column with more than it survives long enough to do. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto * fix(activity): offer Cancel in the phase that can honour it The cancellable flag was set at both ends of an operation's life and was wrong at both, in opposite directions. A queued operation is cancellable whatever it is. EnqueueModelOp and EnqueueBackendOp select on the operation context, so cancelling one that is still waiting releases the delivery goroutine and abandonQueued retires it: the worker never sees it, nothing is downloaded, nothing is deleted. markQueued nevertheless wrote Cancellable: !deletion, so a queued removal reported cancellable: false and the UI hid the Cancel button in the one window where pressing it both works and leaves no trace. A removal queued behind a large install was stuck there until the install finished. A running removal is not cancellable at all. DeleteModel and DeleteBackend take no context, and modelHandler only checks the operation context after the call returns, so a "cancelled" verdict would land after the model was already gone. Both handlers nevertheless wrote Cancellable: true unconditionally at entry, ahead of the op.Delete branch, offering a Cancel button the server cannot honour. So the queued phase is more cancellable than the running phase, which is the reverse of the usual shape. markQueued now reports true unconditionally, and the handler-entry writes report !op.Delete. Both sites carry a comment saying why, because reading either one alone suggests the other is a bug. GalleryStore.Create keeps !op.Delete: it runs at dequeue, so its value already describes the running phase. Its comment now says so. Specs cover queued removal, queued install, running removal and running install through the handlers, plus the queued-removal case through /api/operations where the flag is consumed, plus the behaviour the whole asymmetry rests on: a removal cancelled while queued never reaches the worker and deletes nothing. No existing spec asserted the old values. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto * fix(activity): clamp the installer message, and add a real-binary e2e spec Running the page against a real local-ai showed the legacy installer message wrapping to three lines and dominating the card: it embeds an absolute file path, so it is both long and a single unbreakable token. One line, ellipsised, full text in the title, matching what the error string already does. The spec that found it runs with no route stubbing at all. Every other spec here stubs /api/operations, which is how a payload the server cannot emit (isDeletion true on a live operation) stayed green through a full review while the UI rendered a removal as an install. It is skipped unless LOCALAI_REAL_BINARY is set, so CI is unaffected. Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/http/react-ui/e2e/activity-page.spec.js | 417 ++++++++++++++++ .../e2e/model-artifact-operation.spec.js | 22 +- .../react-ui/e2e/operations-strip.spec.js | 216 +++++++++ .../react-ui/e2e/page-render-smoke.spec.js | 1 + .../react-ui/e2e/real-binary-activity.spec.js | 73 +++ .../react-ui/public/locales/de/admin.json | 80 ++++ core/http/react-ui/public/locales/de/nav.json | 6 +- .../react-ui/public/locales/en/admin.json | 80 ++++ core/http/react-ui/public/locales/en/nav.json | 6 +- .../react-ui/public/locales/es/admin.json | 80 ++++ core/http/react-ui/public/locales/es/nav.json | 6 +- .../react-ui/public/locales/id/admin.json | 80 ++++ core/http/react-ui/public/locales/id/nav.json | 6 +- .../react-ui/public/locales/it/admin.json | 80 ++++ core/http/react-ui/public/locales/it/nav.json | 6 +- .../react-ui/public/locales/ko/admin.json | 80 ++++ core/http/react-ui/public/locales/ko/nav.json | 6 +- .../react-ui/public/locales/zh-CN/admin.json | 80 ++++ .../react-ui/public/locales/zh-CN/nav.json | 6 +- core/http/react-ui/src/App.css | 452 ++++++++++++++---- .../react-ui/src/components/OperationCard.jsx | 233 +++++++++ .../react-ui/src/components/OperationsBar.jsx | 346 +++++++------- core/http/react-ui/src/components/Sidebar.jsx | 13 + .../src/components/console/consoleConfig.js | 6 + .../src/contexts/OperationsContext.jsx | 161 ++++++- core/http/react-ui/src/pages/Activity.jsx | 269 +++++++++++ core/http/react-ui/src/router.jsx | 6 + core/http/react-ui/src/utils/api.js | 2 + core/http/react-ui/src/utils/config.js | 1 + core/http/routes/ui_api.go | 45 +- .../routes/ui_api_operations_history_test.go | 141 ++++++ core/http/routes/ui_api_operations_test.go | 248 ++++++++++ core/services/distributed/gallery.go | 102 +++- core/services/distributed/gallery_test.go | 265 ++++++++++ .../galleryop/backend_delete_optype_test.go | 149 ++++++ core/services/galleryop/backends.go | 8 +- .../galleryop/cancellable_phase_test.go | 199 ++++++++ core/services/galleryop/deletion_flag_test.go | 58 +++ core/services/galleryop/enqueue.go | 34 +- core/services/galleryop/export_test.go | 11 + core/services/galleryop/history.go | 163 +++++++ core/services/galleryop/history_ring_test.go | 177 +++++++ core/services/galleryop/history_store_test.go | 270 +++++++++++ core/services/galleryop/history_test.go | 71 +++ core/services/galleryop/models.go | 8 +- .../galleryop/opcache_history_test.go | 176 +++++++ core/services/galleryop/operation.go | 227 +++++++++ core/services/galleryop/service.go | 34 +- docs/content/features/authentication.md | 3 +- docs/content/features/backends.md | 5 + docs/content/features/distributed-mode.md | 21 +- docs/content/features/model-gallery.md | 7 +- docs/content/getting-started/models.md | 4 +- docs/content/operations/_index.en.md | 1 + docs/content/operations/activity.md | 215 +++++++++ 55 files changed, 5130 insertions(+), 332 deletions(-) create mode 100644 core/http/react-ui/e2e/activity-page.spec.js create mode 100644 core/http/react-ui/e2e/operations-strip.spec.js create mode 100644 core/http/react-ui/e2e/real-binary-activity.spec.js create mode 100644 core/http/react-ui/src/components/OperationCard.jsx create mode 100644 core/http/react-ui/src/pages/Activity.jsx create mode 100644 core/http/routes/ui_api_operations_history_test.go create mode 100644 core/services/distributed/gallery_test.go create mode 100644 core/services/galleryop/backend_delete_optype_test.go create mode 100644 core/services/galleryop/cancellable_phase_test.go create mode 100644 core/services/galleryop/deletion_flag_test.go create mode 100644 core/services/galleryop/export_test.go create mode 100644 core/services/galleryop/history.go create mode 100644 core/services/galleryop/history_ring_test.go create mode 100644 core/services/galleryop/history_store_test.go create mode 100644 core/services/galleryop/history_test.go create mode 100644 core/services/galleryop/opcache_history_test.go create mode 100644 docs/content/operations/activity.md 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 =