From 58ea2f5d79a10edf385cd8363fcd2a2c1b53b81a Mon Sep 17 00:00:00 2001 From: "mudler's LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:16:08 +0200 Subject: [PATCH] feat(ui): give Operate and Studio a front door, and fix two layout regressions (#11305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): give Operate a front door and fold six rail groups into four Opening Operate ran firstVisiblePath() and landed on Backends, because Backends happens to be written first in operateConsole.groups. The section that should answer "is anything wrong" opened on a package manager, and nothing was reported until you visited it. Adds /app/operate. Its one irreplaceable block is "Needs attention", which is empty when nothing is wrong and says so in a line rather than rendering a reassuring green panel. It collects stale backends, failed operations and unhealthy nodes. Everything else on the page is a summary you could already assemble by visiting four others. The rail regroups from six headings to four: Inference and Activity were both "the runtime right now", Access and System were both administration. No destination is removed and no gate changes, so isConsoleItemVisible and consolePaths are untouched. Overview leads the first group, which is what makes firstVisiblePath() return it without knowing it exists. Rail entries now carry a signal beside the label. This does not replace the sidebar badge and is not built as if it does: the badge stays on the always-visible sidebar entry for the reason recorded in Sidebar.jsx, that the rail exists only on Operate routes and can be collapsed. The signals are orientation while inside Operate, so they are aria-hidden and nothing urgent depends on them alone. OperateSummaryContext polls once for the whole console, following OperationsContext, which exists because per-consumer setInterval against one endpoint was the defect it fixed. It is mounted by ConsoleLayout for the Operate console only, so "poll only while in Operate" needs no route check. Built on usePolling, so it pauses on a hidden tab. Operations are read from OperationsContext rather than polled a second time, and each source degrades to no-signal on its own so one dead endpoint cannot blank the rest. It reads the cached GET /api/backends/upgrades and never the POST that forces a real registry check. Traces and Usage get no signal yet: /api/traces returns the list, so a count would mean fetching every trace to render one number. A counts endpoint is the honest fix and is scoped separately. Full e2e suite green (369 passed, 4 skipped), including a render-smoke entry for the new route. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(ui): open Studio on what this machine can actually make Studio was a tab strip over six generators that opened on Images, which was never a decision, only the first entry in BASE_TABS. Nothing said which modalities this installation could run, so the way to learn that video had no model was to pick the tab and find an empty select. Adds an overview tab and makes it the fallback. Explicit tabs still win, so existing deep links keep working; anything unrecognised or gated now lands on the overview rather than Images. Each tab carries a dot: filled when an installed model advertises that modality, hollow when nothing serves it. That is the feature in one detail, turning the strip from navigation into a report of what the machine can do before anything is clicked. The dot is aria-hidden because the overview states the same facts in words and the dots change as models load. Two kinds of unavailable, which had to stop looking alike: - switched off, via a permission: no tab and no lane, unchanged - available with no model: a lane, and a route to installing one Studio now owns one MODALITIES table so the tab strip and the overview cannot disagree about what exists, and calls useModels() once, unfiltered, grouping in the browser. useModels(capability) fetches the whole list and filters locally, so a hook per modality would have been six identical requests to /api/models/capabilities on every mount. There is a test for that. Recent outputs read every localStorage store through a new readAllMediaHistory(), which avoids mounting five hooks that carry save timers the overview has no use for. 3D is read separately through use3DHistory rather than folded in: its entries are GLB blobs in IndexedDB, so they cannot come from the same synchronous read. Typical cost is the median of this machine's own history, not a guess, and renders as a dash when there is nothing to go on. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * fix(ui): stop the stat cards and the console rail breaking on small screens Two unrelated causes behind one report that /app/manage looks wrong when the window is narrow. The stat cards were being laid out by the wrong rule at every width. Two different components both claimed `.stat-grid`: the dashboard card strip that holds .stat-card children, and the detail-pane StatGrid the split views introduced further down App.css. Being later, the second won every shared property, so the cards got its 120px columns and its 1px hairline gap in place of their own 180px columns and spacing-md. Four cards were packed onto a row that fits two, labels wrapped to three lines and clipped, and the icon crowded the value. Renamed the strip to `.stat-cards`, after the children it actually holds, which also removes the mismatch of a `.stat-grid` container full of `.stat-card`s. The split-view component keeps `.stat-grid` and its BEM parts. The expanded console rail had no bounded height. Thirteen destinations stacked in one column is taller than a phone, so opening the menu pushed the page's own heading past the fold: the menu replaced the page rather than annotating it. Capped at 55vh with internal scrolling below 768px, so the content behind stays reachable. Both are asserted on behaviour rather than markup: no stat-card label may be clipped, the card gap must not be the detail pane's hairline, and expanding the rail must leave the page heading on screen. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(ui): retemper the palette to localai.io and add the lane primitive The token half of the style transfer, plus the shared list idiom the two overviews had each grown their own copy of. theme.css moves from Nord to the website's palette, variable names preserved so every consumer moves with it: ground #13171f -> #0d1117, accent frost cyan #88c0d0 -> action blue #4f8cff, success sage -> mint #56d6a4, warning -> the amber #f1b95d the site spends only on the thing asking for a decision. Eyebrows go mint. Dividers become an opaque #29384a hairline rather than alpha over a varying surface, which is what makes stacked surfaces read crisply on the site. Light is derived, not inverted. The site ships one theme and never had to answer this, but the app does: blue darkens to #2f62d8, mint to #0d8b60 and amber to #8a5d0b, all clearing 4.5:1 on a cool paper ground, where the dark-mode values sit near 2:1. Same three roles, different values. Three files restate the palette because CSS variables cannot reach them: cmTheme.js (the whole CodeMirror theme), VoiceVisualizer and WaveformPlayer (canvas). Left alone they would have quietly kept the app half-Nord. The `.lane` primitive replaces the near-identical row CSS that OperateOverview and StudioOverview had each written: a full-bleed row on a hairline that insets on hover, with no card and no shadow. Callers supply only the column template. Both pages now use it, along with `.lane-head` for section rhythm and a `.page-pad` container for top-level pages outside a console shell — without which Studio sat flush against the sidebar with its eyebrow clipped. Studio's tab strip wraps rather than running off the edge at narrow widths. Full e2e suite: 386 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(ui): put Home's resident models on lanes and give the footer one line Home's status line was three chips saying a thing was true. It now reports figures: how many models are resident, how many nodes are healthy, what share of memory is in use, set in tabular monospace so the digits line up. A chip answers whether; a figure answers how much, which is what someone opening the page at a glance is after. Resident models move from status chips to lanes, with the id set in a new `.lane__name--id` because an id is something you might type or paste and the UI face makes it read as a label. /api/system-information carries only the id, so there is deliberately no backend or memory column: inventing one would mean a server change this does not make. The footer was three centred rows and cost the bottom sixth of every page for chrome. It is one line now, version left and links right, wrapping to centred when the viewport is too narrow to hold both. Every link it had, it keeps. Full e2e suite: 392 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * fix(ui): correct three contrast failures and stop a guaranteed-404 poll A contrast audit of the new palette found three values below WCAG AA, one of which the previous commit message claimed was fine: - White on the #4f8cff button is 3.22:1, which is large-text only. The website does exactly this, but a button label in an app is not large text, so the label goes to dark ink at 5.88:1. Light mode keeps white, which is 5.44:1 on its darker blue. - Light-mode success was 4.08:1 on paper, not the 4.5 claimed. Darkened to #0a734f, 5.56:1. - Nord red was already 4.28:1 on raised surfaces, a pre-existing miss carried over unexamined. Lifted to #c96f78, 5.02:1. Lanes gain the two states they were missing: a 44px target on coarse pointers, matching what EntityRail already does so the two list idioms feel the same under a thumb, and a reduced-motion variant that keeps the background feedback while dropping the hover inset, which is a position change. The Operate summary no longer asks for /api/nodes on a single-node install. The cluster API answers 503 when distributed mode is off, so it was a guaranteed miss every fifteen seconds; it is now gated on useDistributedMode, the same condition the rail already uses for the Nodes entry. Full e2e suite: 392 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * fix(ui): restore the gap between overview blocks, and stop claiming zero nodes Two defects a design review surfaced. `.lane-head:first-child { margin-top: 0 }` was meant to stop the first block on a page carrying a top margin. But every
makes its lane-head a first child, so the reset applied to all of them and the gap between blocks vanished: "Sections" sat flush against the attention row above it. The header supplies its own bottom margin, so a uniform top margin is correct everywhere. The Cluster summary read "0 nodes" on a single-node install, which looks like a fault when the cluster API is simply switched off. It now says "Single node". Full e2e suite: 392 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(ui): open dark by default, and stop clipping the collapsed sidebar footer Dark is the identity rather than a preference: localai.io ships one theme and it is this one, so an install should look like LocalAI before anyone has chosen anything. The OS setting no longer selects light on first load. The toggle still does, and a stored choice wins forever after, which the tests assert both ways. The collapsed sidebar footer stacked its controls but kept the expanded row's inline padding, so their edges were clipped against the 51px rail. Full e2e suite: 394 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(api): count traces server-side and give the Operate overview real totals The overview's headline block had no source. /api/traces returns the trace list, so "37 errors in 24h" meant fetching every buffered exchange to count it in the browser — waste that grows with the buffer, to produce three integers. Adds GET /api/traces/summary: totals, failures, p95 and a bucketed series for sparklines, over a window that defaults to 24 hours and is capped at a week. Deliberate calls, each with a spec: - A 4xx is the caller getting it wrong, not the installation being unhealthy, so only 5xx and transport errors count as failures. - p95 is a nearest-rank percentile rather than the slowest request, which is what a max would report and what makes latency panels lie. - Buckets are oldest-first so a sparkline reads left to right, and the slice is never nil: nil serialises as null and breaks .map() on the other side, which is a silent runtime error rather than an empty chart. - Exchanges outside the window are not counted at all. The route is registered before /api/traces/:id so "summary" is not captured as a trace ID. On the client, Traces and Usage gain the rail signals they were shipped without, the Observability section summary now states counts instead of listing its destinations, and an installation that has served nothing says so rather than showing three zeroes dressed as telemetry. Sparkline is a bare stroke with an emphasised endpoint and no axes: the figure above it already states the value, so its only job is the shape. Go: 185 middleware specs pass. Full e2e suite: 396 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * fix(ui): stop the memory chart calling a trade-off an error The VRAM-by-context chart rendered any build over the limit in error red, and escalated the verdict to the error tone as soon as two context sizes crossed it. But an over-limit build still installs — #11288 keeps a test on exactly that — so red overstates what is happening. A model that fits at 32k and not 64k is a trade-off, not a fault. Over-limit bars and the limit line now use the warning tone, which is the constraint colour used everywhere else in this branch: know what you are doing, not you may not. The error tone is reserved for "fits nowhere", where the model genuinely cannot run on this host. Full e2e suite: 397 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(ui): give the new surfaces orchestrated motion Uses the reveal system already in the codebase rather than adding a library: pageReveal, .reveal-stagger and staggerStyle() were built for exactly this, and anime.js would be ~17KB duplicating four lines of CSS for list reveals. The overview's headline figures, attention rows and section lanes stagger in, as do Studio's modality lanes and recent outputs, so a page assembles in the order it is read instead of appearing all at once. Two additions beyond stagger. Rail signals transition on opacity when a poll lands, so a number changing reads as an update rather than a jump cut, and it stays on the compositor so it cannot reflow the rail. The attention block animates its left edge in — the one thing on the page that should announce itself, and on the border rather than the text so nothing moves under a reader. Both are dropped entirely under prefers-reduced-motion, alongside the lane hover inset already handled. Full e2e suite: 397 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(ui): put the generators on a hairline field stack and record the request The workbench treatment from the mocks, applied where it costs least: both changes land on shared surfaces, so all six generators get them at once rather than drifting apart page by page. The control column stops being a shadowed card of boxed groups and becomes a hairline field stack — the panel is the page's left half, not an object floating on it — with uppercase micro-labels matching the eyebrow treatment used elsewhere. Because .media-controls is shared, Images, Video, 3D, Speech, Sound and Audio FX all move together. RequestPanel shows the request the form actually built, with a copy-as-curl. LocalAI is API-first and Studio is the best place in the app to teach its own endpoints: the form stops being a black box, and a result worth keeping can be reproduced from a shell without reverse-engineering which fields the page sent. It records what was sent rather than what the form currently holds, and renders nothing until a request has been made — a panel describing a request nobody made is a tutorial, not a record. Wired into Images and Speech. Full e2e suite: 401 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(ui): make Chat a transcript instead of a bubble thread Rounded, filled, asymmetric bubbles fight a system built on hairlines, and they carry the speaker in shape and side rather than in words. The assistant side had already given up its bubble; this finishes the job. Both roles now run full width down one column, separated by a rule, each with a mono role label. The user turn keeps a left edge in the action tone so the two are still told apart at a glance, without a fill or a corner radius. The avatars go: the accent and the label carry the speaker, so the glyph was decoration once neither side had a bubble. Saying who is speaking in words rather than in geometry is also what survives being read aloud, printed, or looked at by someone who cannot pick the sides apart by colour. Full e2e suite: 404 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * feat(ui): dress the API reference in LocalAI's palette The Swagger page was the last surface still shipping in someone else's colours, which is conspicuous now that everything it links from is dark. Swagger UI has no theming hook, so rather than fork it we serve our own index ahead of the library's wildcard and restate the palette over its stylesheet. The library's own bundle and assets are still what load, so a swagger-ui upgrade cannot silently break the page — this is a skin, not a fork. Two things needed real care. Swagger tints the entire operation row per method via .opblock.opblock-post and friends, so the palette had to match that specificity rather than reach for !important; the method now lives on one edge instead of washing across the row, because a page where every row is a status colour has no status colour left. And the filled method chip put white on pale green, which was the least readable thing on the page — it is an outlined mono chip now, carrying the method in its border and text. Palette values are copied from theme.css rather than referenced: this page is served by Go and never sees the app's CSS. The comment says so, and says to keep them in step. Go: routes and middleware suites pass. Full e2e suite: 405 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] * fix(ui): make tall split-view pages reachable, repair the Agents header, scale titles Three things found by actually using the app rather than measuring it. **Host was unusable.** The shell above a split view is overflow:hidden so the document cannot grow, which left anything taller than the viewport simply unreachable — and Host stacks a resources card, four stat cards and a tab bar above its split, so the bottom of the pane fell off at every window height with nothing to scroll. Every sweep I ran for this was horizontal, which is why it kept coming back clean. The page now scrolls inside the pinned shell. The pane keeps its own scroller: letting it grow instead pushes the document taller and stretches the rail to match, which is the regression e2e/discover-height.spec.js exists to catch, and which the first version of this fix duly caused. **The Agents header controls were unstyled** — "Create Agent" was rendering with the browser's default chrome. The markup had been mangled at some point: six unrelated classes merged into one string on the link, and the label and button left with none at all and empty icons. Repaired, with the inline flex replaced by a shared .header-actions class. **Page titles take the editorial scale from the site**: larger, tracked at -0.04em, on a line height near 1, so a two-word title reads as a statement rather than a label. The typeface is unchanged — DESIGN.md keeps the existing type system — so the whole difference is scale, tracking and leading, which is where the site gets its voice from. This was the biggest reason the running app still did not look like the mocks. Full e2e suite: 404 passed, 4 skipped. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/http/endpoints/localai/traces.go | 30 ++ core/http/middleware/trace_summary.go | 109 ++++ core/http/middleware/trace_summary_test.go | 79 +++ core/http/react-ui/e2e/admin-console.spec.js | 4 +- .../http/react-ui/e2e/chat-transcript.spec.js | 55 ++ core/http/react-ui/e2e/console-narrow.spec.js | 68 +++ core/http/react-ui/e2e/home-lanes.spec.js | 76 +++ core/http/react-ui/e2e/models-gallery.spec.js | 13 + .../react-ui/e2e/operate-overview.spec.js | 158 ++++++ .../react-ui/e2e/page-render-smoke.spec.js | 1 + .../http/react-ui/e2e/studio-overview.spec.js | 146 +++++ .../react-ui/e2e/studio-workbench.spec.js | 49 ++ core/http/react-ui/e2e/theme-default.spec.js | 15 + core/http/react-ui/e2e/threed-gen.spec.js | 4 +- .../react-ui/public/locales/de/admin.json | 30 ++ .../http/react-ui/public/locales/de/chat.json | 3 + .../http/react-ui/public/locales/de/home.json | 7 +- .../react-ui/public/locales/de/media.json | 31 ++ core/http/react-ui/public/locales/de/nav.json | 7 +- .../react-ui/public/locales/en/admin.json | 30 ++ .../http/react-ui/public/locales/en/chat.json | 3 + .../http/react-ui/public/locales/en/home.json | 7 +- .../react-ui/public/locales/en/media.json | 33 +- core/http/react-ui/public/locales/en/nav.json | 7 +- .../react-ui/public/locales/es/admin.json | 30 ++ .../http/react-ui/public/locales/es/chat.json | 3 + .../http/react-ui/public/locales/es/home.json | 7 +- .../react-ui/public/locales/es/media.json | 31 ++ core/http/react-ui/public/locales/es/nav.json | 7 +- .../react-ui/public/locales/id/admin.json | 30 ++ .../http/react-ui/public/locales/id/chat.json | 3 + .../http/react-ui/public/locales/id/home.json | 7 +- .../react-ui/public/locales/id/media.json | 33 +- core/http/react-ui/public/locales/id/nav.json | 7 +- .../react-ui/public/locales/it/admin.json | 30 ++ .../http/react-ui/public/locales/it/chat.json | 3 + .../http/react-ui/public/locales/it/home.json | 7 +- .../react-ui/public/locales/it/media.json | 31 ++ core/http/react-ui/public/locales/it/nav.json | 7 +- .../react-ui/public/locales/ko/admin.json | 30 ++ .../http/react-ui/public/locales/ko/chat.json | 3 + .../http/react-ui/public/locales/ko/home.json | 7 +- .../react-ui/public/locales/ko/media.json | 31 ++ core/http/react-ui/public/locales/ko/nav.json | 7 +- .../react-ui/public/locales/zh-CN/admin.json | 30 ++ .../react-ui/public/locales/zh-CN/chat.json | 3 + .../react-ui/public/locales/zh-CN/home.json | 7 +- .../react-ui/public/locales/zh-CN/media.json | 31 ++ .../react-ui/public/locales/zh-CN/nav.json | 7 +- core/http/react-ui/src/App.css | 510 ++++++++++++++++-- .../react-ui/src/components/ManageSummary.jsx | 2 +- .../react-ui/src/components/RequestPanel.jsx | 54 ++ .../react-ui/src/components/Sparkline.jsx | 40 ++ .../src/components/VoiceVisualizer.jsx | 2 +- .../src/components/audio/WaveformPlayer.jsx | 2 +- .../src/components/console/ConsoleLayout.jsx | 24 +- .../src/components/console/consoleConfig.js | 41 +- .../src/contexts/OperateSummaryContext.jsx | 154 ++++++ .../react-ui/src/contexts/ThemeContext.jsx | 5 +- .../react-ui/src/hooks/useMediaHistory.js | 15 + core/http/react-ui/src/pages/Agents.jsx | 16 +- core/http/react-ui/src/pages/Chat.jsx | 6 + core/http/react-ui/src/pages/FineTune.jsx | 2 +- core/http/react-ui/src/pages/Home.jsx | 40 +- core/http/react-ui/src/pages/ImageGen.jsx | 7 + core/http/react-ui/src/pages/Models.jsx | 5 +- .../react-ui/src/pages/OperateOverview.jsx | 164 ++++++ core/http/react-ui/src/pages/Studio.jsx | 147 +++-- .../react-ui/src/pages/StudioOverview.jsx | 134 +++++ core/http/react-ui/src/pages/TTS.jsx | 5 + core/http/react-ui/src/router.jsx | 4 + core/http/react-ui/src/theme.css | 239 ++++---- core/http/react-ui/src/utils/api.js | 2 + core/http/react-ui/src/utils/cmTheme.js | 71 +-- core/http/react-ui/src/utils/config.js | 1 + core/http/routes/localai.go | 5 + core/http/routes/swagger_theme.go | 198 +++++++ docs/content/features/authentication.md | 2 +- docs/content/operations/overview.md | 61 +++ 79 files changed, 3027 insertions(+), 288 deletions(-) create mode 100644 core/http/middleware/trace_summary.go create mode 100644 core/http/middleware/trace_summary_test.go create mode 100644 core/http/react-ui/e2e/chat-transcript.spec.js create mode 100644 core/http/react-ui/e2e/console-narrow.spec.js create mode 100644 core/http/react-ui/e2e/home-lanes.spec.js create mode 100644 core/http/react-ui/e2e/operate-overview.spec.js create mode 100644 core/http/react-ui/e2e/studio-overview.spec.js create mode 100644 core/http/react-ui/e2e/studio-workbench.spec.js create mode 100644 core/http/react-ui/e2e/theme-default.spec.js create mode 100644 core/http/react-ui/src/components/RequestPanel.jsx create mode 100644 core/http/react-ui/src/components/Sparkline.jsx create mode 100644 core/http/react-ui/src/contexts/OperateSummaryContext.jsx create mode 100644 core/http/react-ui/src/pages/OperateOverview.jsx create mode 100644 core/http/react-ui/src/pages/StudioOverview.jsx create mode 100644 core/http/routes/swagger_theme.go create mode 100644 docs/content/operations/overview.md diff --git a/core/http/endpoints/localai/traces.go b/core/http/endpoints/localai/traces.go index 7e9f1216f..2ae8f5b66 100644 --- a/core/http/endpoints/localai/traces.go +++ b/core/http/endpoints/localai/traces.go @@ -3,6 +3,7 @@ package localai import ( "net/http" "strconv" + "time" "github.com/labstack/echo/v4" "github.com/mudler/LocalAI/core/http/middleware" @@ -85,6 +86,35 @@ func GetAPITracesEndpoint() echo.HandlerFunc { } } +// GetAPITracesSummaryEndpoint returns counted totals over a recent window +// @Summary Summarize recent API traces +// @Description Returns request, failure and latency totals over a recent window, plus a bucketed series for sparklines. Exists so callers wanting three numbers do not have to fetch the whole trace list and count it themselves. +// @Tags monitoring +// @Produce json +// @Param hours query int false "Window in hours (default 24, max 168)" +// @Success 200 {object} middleware.TraceSummary "Counted trace totals" +// @Router /api/traces/summary [get] +func GetAPITracesSummaryEndpoint() echo.HandlerFunc { + return func(c echo.Context) error { + hours := 24 + if raw := c.QueryParam("hours"); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + hours = v + } + } + // A week is plenty for a dashboard, and the trace buffer is bounded + // anyway; an unbounded window would just scan the whole buffer. + if hours > 168 { + hours = 168 + } + return c.JSON(http.StatusOK, middleware.GetTracesSummary(time.Duration(hours)*time.Hour, traceSummaryBuckets)) + } +} + +// Enough columns for a sparkline to show a shape, few enough that each one +// still holds a meaningful count on a quiet installation. +const traceSummaryBuckets = 12 + // GetAPITraceEndpoint returns a single API trace with its full payload // @Summary Get one API trace // @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response diff --git a/core/http/middleware/trace_summary.go b/core/http/middleware/trace_summary.go new file mode 100644 index 000000000..f5d8dbf39 --- /dev/null +++ b/core/http/middleware/trace_summary.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT + +package middleware + +import ( + "math" + "slices" + "time" +) + +// TraceSummary is the counted view of the trace buffer. +// +// It exists so a caller that wants "how many, how many failed, how slow" does +// not have to fetch every exchange and count them in the browser. The Operate +// overview needs exactly those three numbers, and the trace list is capped in +// the thousands, so shipping it across the wire to produce a single integer is +// waste that grows with the buffer. +type TraceSummary struct { + Total int `json:"total"` + Errors int `json:"errors"` + P95Millis int64 `json:"p95_ms"` + WindowHours int `json:"window_hours"` + Buckets []TraceBucket `json:"buckets"` +} + +// TraceBucket is one column of a sparkline: oldest first, so the series reads +// left to right the way a chart is drawn. +type TraceBucket struct { + Start time.Time `json:"start"` + Count int `json:"count"` + Errors int `json:"errors"` +} + +// GetTracesSummary counts the buffered exchanges over the given window. +func GetTracesSummary(window time.Duration, buckets int) TraceSummary { + return summarize(GetTraces(), window, buckets) +} + +func summarize(traces []APIExchange, window time.Duration, buckets int) TraceSummary { + if buckets < 1 { + buckets = 1 + } + now := time.Now() + cutoff := now.Add(-window) + + summary := TraceSummary{ + WindowHours: int(window.Hours()), + // Never nil: a nil slice serialises as null and breaks .map() on the + // other side, which is a silent runtime error rather than an empty chart. + Buckets: make([]TraceBucket, buckets), + } + + bucketWidth := window / time.Duration(buckets) + for i := range summary.Buckets { + summary.Buckets[i].Start = cutoff.Add(time.Duration(i) * bucketWidth) + } + + durations := make([]time.Duration, 0, len(traces)) + for _, t := range traces { + if t.Timestamp.Before(cutoff) { + continue + } + summary.Total++ + failed := isFailure(t) + if failed { + summary.Errors++ + } + durations = append(durations, t.Duration) + + // Clamp rather than skip: a request timestamped a hair in the future + // (clock skew, or arriving mid-call) still belongs in the newest column. + idx := int(t.Timestamp.Sub(cutoff) / bucketWidth) + if idx >= buckets { + idx = buckets - 1 + } + if idx < 0 { + idx = 0 + } + summary.Buckets[idx].Count++ + if failed { + summary.Buckets[idx].Errors++ + } + } + + summary.P95Millis = percentileMillis(durations, 0.95) + return summary +} + +// A 4xx is the caller getting it wrong, which is not the installation being +// unhealthy. Only 5xx and a transport-level error count against the runtime. +func isFailure(t APIExchange) bool { + return t.Error != "" || t.Response.Status >= 500 +} + +func percentileMillis(durations []time.Duration, p float64) int64 { + if len(durations) == 0 { + return 0 + } + slices.Sort(durations) + // Nearest-rank: the smallest value at or above the pth percentile. + rank := int(math.Ceil(p*float64(len(durations)))) - 1 + if rank < 0 { + rank = 0 + } + if rank >= len(durations) { + rank = len(durations) - 1 + } + return durations[rank].Milliseconds() +} diff --git a/core/http/middleware/trace_summary_test.go b/core/http/middleware/trace_summary_test.go new file mode 100644 index 000000000..afd888c2e --- /dev/null +++ b/core/http/middleware/trace_summary_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT + +package middleware + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("API trace summary", func() { + exchange := func(age time.Duration, status int, dur time.Duration) APIExchange { + return APIExchange{ + Timestamp: time.Now().Add(-age), + Duration: dur, + Response: APIExchangeResponse{Status: status}, + } + } + + It("counts only what falls inside the window", func() { + traces := []APIExchange{ + exchange(1*time.Hour, 200, 10*time.Millisecond), + exchange(2*time.Hour, 200, 10*time.Millisecond), + // Older than the window: must not be counted at all. + exchange(48*time.Hour, 500, 10*time.Millisecond), + } + s := summarize(traces, 24*time.Hour, 6) + Expect(s.Total).To(Equal(2)) + Expect(s.Errors).To(BeZero()) + }) + + It("treats 5xx and a transport error as failures, but not 4xx", func() { + traces := []APIExchange{ + exchange(time.Minute, 500, time.Millisecond), + exchange(time.Minute, 503, time.Millisecond), + // A client sending a bad request is not the server failing. + exchange(time.Minute, 404, time.Millisecond), + exchange(time.Minute, 200, time.Millisecond), + } + traces[3].Error = "connection reset" + + s := summarize(traces, 24*time.Hour, 6) + Expect(s.Total).To(Equal(4)) + Expect(s.Errors).To(Equal(3)) + }) + + It("reports p95 as a real percentile rather than the slowest request", func() { + traces := make([]APIExchange, 0, 100) + for i := 1; i <= 100; i++ { + traces = append(traces, exchange(time.Minute, 200, time.Duration(i)*time.Millisecond)) + } + s := summarize(traces, 24*time.Hour, 6) + // 95th of 1..100ms, not the 100ms max. + Expect(s.P95Millis).To(BeNumerically("~", 95, 1)) + }) + + It("buckets oldest-first so a sparkline reads left to right", func() { + traces := []APIExchange{ + exchange(30*time.Minute, 200, time.Millisecond), + exchange(30*time.Minute, 200, time.Millisecond), + exchange(5*time.Hour, 200, time.Millisecond), + } + s := summarize(traces, 6*time.Hour, 6) + Expect(s.Buckets).To(HaveLen(6)) + Expect(s.Buckets[0].Count).To(Equal(1), "the 5h-old request lands in the first bucket") + Expect(s.Buckets[5].Count).To(Equal(2), "the recent pair lands in the last") + }) + + It("returns an empty, non-nil summary when nothing has been traced", func() { + s := summarize(nil, 24*time.Hour, 6) + Expect(s.Total).To(BeZero()) + Expect(s.Errors).To(BeZero()) + Expect(s.P95Millis).To(BeZero()) + // A nil slice serialises as null and breaks .map() in the browser. + Expect(s.Buckets).NotTo(BeNil()) + Expect(s.Buckets).To(HaveLen(6)) + }) +}) diff --git a/core/http/react-ui/e2e/admin-console.spec.js b/core/http/react-ui/e2e/admin-console.spec.js index 1a039eba3..6bd459feb 100644 --- a/core/http/react-ui/e2e/admin-console.spec.js +++ b/core/http/react-ui/e2e/admin-console.spec.js @@ -5,7 +5,9 @@ test.describe('Admin console', () => { await page.goto('/app/backends') const rail = page.locator('.console-rail') await expect(rail).toBeVisible() - for (const group of ['Inference', 'Cluster', 'Observability', 'Access', 'System']) { + // Four groups since the overview landed: Inference folded into Runtime + // (both are "the runtime right now"), Access and System into Administration. + for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) { await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible() } }) diff --git a/core/http/react-ui/e2e/chat-transcript.spec.js b/core/http/react-ui/e2e/chat-transcript.spec.js new file mode 100644 index 000000000..8164e8652 --- /dev/null +++ b/core/http/react-ui/e2e/chat-transcript.spec.js @@ -0,0 +1,55 @@ +import { test, expect } from './coverage-fixtures.js' + +// Chat reads as a transcript rather than a bubble thread (mock 04). + +const CHAT = { + chats: [{ + id: 'c1', name: 'Transcript', model: 'mock-model', + history: [ + { role: 'user', content: 'Which backends do I have?' }, + { role: 'assistant', content: 'Seven are installed.' }, + ], + }], + activeChatId: 'c1', +} + +test.describe('Chat transcript', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(chat => { + localStorage.setItem('localai_chats_data', JSON.stringify(chat)) + }, CHAT) + await page.goto('/app/chat') + }) + + test('neither role is a filled, rounded bubble', async ({ page }) => { + const user = page.locator('.chat-message-user .chat-message-content').first() + await expect(user).toBeVisible() + const cs = await user.evaluate(el => { + const s = getComputedStyle(el) + return { radius: s.borderTopLeftRadius, shadow: s.boxShadow } + }) + // A rounded filled bubble carries the speaker in shape and side; a + // transcript carries it in words, which survives being read aloud. + expect(cs.radius).toBe('0px') + expect(cs.shadow).toBe('none') + }) + + test('both turns run full width in one column, not left and right', async ({ page }) => { + const user = page.locator('.chat-message-user').first() + const assistant = page.locator('.chat-message-assistant').first() + const [u, a] = [await user.boundingBox(), await assistant.boundingBox()] + expect(Math.abs(u.x - a.x)).toBeLessThan(2) + }) + + test('every turn says who is speaking', async ({ page }) => { + await expect(page.locator('.chat-message-user .chat-message-model')).toHaveText('You') + await expect(page.locator('.chat-message-assistant .chat-message-model').first()) + .toHaveText('mock-model') + }) + + test('turns are separated by a rule', async ({ page }) => { + const border = await page.locator('.chat-message').first() + .evaluate(el => getComputedStyle(el).borderBottomStyle) + expect(border).toBe('solid') + }) +}) diff --git a/core/http/react-ui/e2e/console-narrow.spec.js b/core/http/react-ui/e2e/console-narrow.spec.js new file mode 100644 index 000000000..e3fda35fe --- /dev/null +++ b/core/http/react-ui/e2e/console-narrow.spec.js @@ -0,0 +1,68 @@ +import { test, expect } from './coverage-fixtures.js' + +// Small-screen behaviour of the Operate console and the dashboard stat cards. +// +// Both defects here are about a narrow viewport but neither is only a narrow +// viewport problem: the stat cards were being laid out by the wrong rule at +// every width, and the rail's height was never bounded. + +test.describe('Operate console on a narrow screen', () => { + test('expanding the rail leaves the page still on screen', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.goto('/app/manage') + + const toggle = page.locator('.console-rail-toggle') + await expect(toggle).toBeVisible() + await toggle.click() + await expect(page.locator('.console-rail-groups')).toBeVisible() + + // Thirteen destinations in one column is taller than a phone. If opening + // the menu pushes the page's own heading past the fold, the menu has + // replaced the page instead of annotating it. + // Manage titles itself with .view-bar__title rather than .page-title. + const heading = page.locator('.page-title, .view-bar__title').first() + const box = await heading.boundingBox() + expect(box).not.toBeNull() + expect(box.y).toBeLessThan(800) + }) + + test('the rail scrolls internally rather than growing without bound', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.goto('/app/manage') + await page.locator('.console-rail-toggle').click() + + const groups = page.locator('.console-rail-groups') + await expect(groups).toBeVisible() + const height = await groups.evaluate(el => el.getBoundingClientRect().height) + expect(height).toBeLessThan(800) + }) +}) + +test.describe('Dashboard stat cards', () => { + // Two components both claimed `.stat-grid`: the dashboard card strip and the + // detail-pane StatGrid added with the split views. The later rule won, so the + // cards were laid out on 120px columns with a 1px gap meant for something + // else, and their labels were clipped. + for (const width of [768, 1024]) { + test(`labels are not clipped at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 1000 }) + await page.goto('/app/manage') + const labels = page.locator('.stat-card__label') + await expect(labels.first()).toBeVisible() + + const clipped = await labels.evaluateAll(els => + els.filter(el => el.scrollWidth > el.clientWidth + 1).map(el => el.textContent)) + expect(clipped).toEqual([]) + }) + } + + test('cards keep the card gap, not the hairline gap of the detail pane grid', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 1000 }) + await page.goto('/app/manage') + const strip = page.locator('.manage-summary') + await expect(strip).toBeVisible() + const gap = await strip.evaluate(el => parseFloat(getComputedStyle(el).columnGap)) + // 1px is the detail-pane StatGrid's hairline; the card strip wants real space. + expect(gap).toBeGreaterThan(4) + }) +}) diff --git a/core/http/react-ui/e2e/home-lanes.spec.js b/core/http/react-ui/e2e/home-lanes.spec.js new file mode 100644 index 000000000..f664e827b --- /dev/null +++ b/core/http/react-ui/e2e/home-lanes.spec.js @@ -0,0 +1,76 @@ +import { test, expect } from './coverage-fixtures.js' + +// Home's resident-model list and the app footer. + +const SYS_INFO = { + backends: ['llama-cpp'], + loaded_models: [{ id: 'qwen3-8b-instruct' }, { id: 'parakeet-tdt-0.6b' }], +} + +async function mockLoaded(page) { + await page.route('**/system', route => route.fulfill({ json: SYS_INFO })) + await page.route('**/v1/models', route => + route.fulfill({ json: { data: [{ id: 'qwen3-8b-instruct' }, { id: 'parakeet-tdt-0.6b' }] } })) +} + +test.describe('Home resident models', () => { + test('resident models read as lanes, not status chips', async ({ page }) => { + await mockLoaded(page) + await page.goto('/app') + const lanes = page.locator('.home-loaded .lane') + await expect(lanes).toHaveCount(2) + // Model ids are identifiers, so they are set in mono like every other + // identifier in the app. + const family = await lanes.first().locator('.lane__name').evaluate( + el => getComputedStyle(el).fontFamily.toLowerCase()) + expect(family).toMatch(/mono|consol|menlo/) + }) + + test('each lane keeps its stop control', async ({ page }) => { + await mockLoaded(page) + await page.goto('/app') + const lane = page.locator('.home-loaded .lane').first() + await expect(lane.getByRole('button', { name: /stop/i })).toBeVisible() + }) + + test('the header reports how many are resident as a figure', async ({ page }) => { + await mockLoaded(page) + await page.goto('/app') + const stat = page.locator('[data-testid="home-stat-loaded"]') + await expect(stat).toBeVisible() + await expect(stat).toContainText('2') + // Digits that sit in a column need to line up. + const numeric = await stat.locator('.home-stat__value').evaluate( + el => getComputedStyle(el).fontVariantNumeric) + expect(numeric).toContain('tabular-nums') + }) + + test('nothing resident still says so', async ({ page }) => { + await page.route('**/system', route => + route.fulfill({ json: { backends: ['llama-cpp'], loaded_models: [] } })) + await page.route('**/v1/models', route => route.fulfill({ json: { data: [{ id: 'a-model' }] } })) + await page.goto('/app') + await expect(page.locator('.home-loaded-empty')).toBeVisible() + await expect(page.locator('.home-loaded .lane')).toHaveCount(0) + }) +}) + +test.describe('App footer', () => { + test('is one line, not three stacked rows', async ({ page }) => { + await page.goto('/app') + const footer = page.locator('.app-footer') + await expect(footer).toBeVisible() + // Three centred rows of chrome cost more vertical space than the content + // they sit under is usually worth. + const height = await footer.evaluate(el => el.getBoundingClientRect().height) + expect(height).toBeLessThan(56) + }) + + test('keeps every link it had', async ({ page }) => { + await page.goto('/app') + const footer = page.locator('.app-footer') + for (const name of [/github/i, /documentation/i, /author/i]) { + await expect(footer.getByRole('link', { name })).toBeVisible() + } + }) +}) diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index baa872d2e..f7d129104 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -1786,6 +1786,19 @@ test.describe("Models Gallery - Discover split view", () => { ); }); + test("a build that fits at some context sizes warns rather than erroring", async ({ + page, + }) => { + await railItem(page, "llama-model").click(); + const verdict = page.locator(".discover__chart-verdict"); + await expect(verdict).toBeVisible(); + // A model that fits at 32k but not 64k is a trade-off, and #11288 keeps a + // test on such a build still being installable. Only "fits nowhere" earns + // the error tone; anything short of that warns. + await expect(verdict).toHaveClass(/discover__chart-verdict--warn/); + await expect(verdict).not.toHaveClass(/discover__chart-verdict--bad/); + }); + test("a host with no GPU gets no chart rather than an unanchored one", async ({ page, }) => { diff --git a/core/http/react-ui/e2e/operate-overview.spec.js b/core/http/react-ui/e2e/operate-overview.spec.js new file mode 100644 index 000000000..0b40208f1 --- /dev/null +++ b/core/http/react-ui/e2e/operate-overview.spec.js @@ -0,0 +1,158 @@ +import { test, expect } from './coverage-fixtures.js' + +// Operate overview (src/pages/OperateOverview.jsx). +// +// The page exists to answer "is anything wrong" without visiting four other +// pages, so the tests are written against that behaviour rather than against +// the markup: what does it say when nothing is wrong, and does each source of +// trouble actually surface. + +const OVERVIEW = '[data-testid="operate-overview"]' +const CLEAR = '[data-testid="operate-attention-clear"]' +const ITEM = '[data-testid="operate-attention-item"]' + +const NO_UPGRADES = {} +const ONE_UPGRADE = { + 'llama-cpp': { + backend_name: 'llama-cpp', + installed_version: '0.9.4', + available_version: '0.9.7', + }, +} + +// A quiet installation: nothing running, nothing stale, every node healthy. +async function mockQuiet(page, { upgrades = NO_UPGRADES, operations = [] } = {}) { + await page.route('**/api/backends/upgrades', route => + route.fulfill({ json: upgrades })) + await page.route('**/api/operations', route => + route.fulfill({ json: operations })) + await page.route('**/api/nodes', route => + route.fulfill({ json: [{ id: 'node-a', status: 'healthy', healthy: true }] })) +} + +test.describe('Operate overview', () => { + test('Operate opens the overview, not whichever page happens to be first', async ({ page }) => { + await mockQuiet(page) + await page.goto('/app') + await page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }).click() + // Today this lands on /app/backends purely because Backends is the first + // entry in operateConsole.groups — an ordering accident, not a decision. + await expect(page).toHaveURL(/\/app\/operate$/) + await expect(page.locator(OVERVIEW)).toBeVisible() + }) + + test('says so plainly when nothing needs attention', async ({ page }) => { + await mockQuiet(page) + await page.goto('/app/operate') + await expect(page.locator(CLEAR)).toBeVisible() + // The empty state is one line, not a panel full of reassuring green. + await expect(page.locator(ITEM)).toHaveCount(0) + }) + + test('a stale backend becomes an attention item naming the version jump', async ({ page }) => { + await mockQuiet(page, { upgrades: ONE_UPGRADE }) + await page.goto('/app/operate') + const item = page.locator(ITEM, { hasText: 'llama-cpp' }) + await expect(item).toBeVisible() + await expect(item).toContainText('0.9.4') + await expect(item).toContainText('0.9.7') + await expect(page.locator(CLEAR)).toHaveCount(0) + }) + + test('a failed operation becomes an attention item', async ({ page }) => { + await mockQuiet(page, { + operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', error: 'no space left on device' }], + }) + await page.goto('/app/operate') + await expect(page.locator(ITEM, { hasText: 'qwen3-8b' })).toBeVisible() + }) + + test('the rail reports backend updates alongside the label', async ({ page }) => { + await mockQuiet(page, { upgrades: ONE_UPGRADE }) + await page.goto('/app/operate') + const backends = page.locator('.console-rail a.nav-item[href="/app/backends"]') + await expect(backends).toBeVisible() + await expect(backends.locator('.nav-signal')).toContainText('1') + }) + + test('the rail groups Runtime, Cluster, Observability and Administration', async ({ page }) => { + await mockQuiet(page) + await page.goto('/app/operate') + const rail = page.locator('.console-rail') + for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) { + await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible() + } + // Six headings for thirteen items was the defect; the old pairs are gone. + for (const gone of ['Inference', 'Access']) { + await expect(rail.locator('.console-group-title', { hasText: new RegExp(`^${gone}$`) })).toHaveCount(0) + } + }) + + test('regrouping does not change what a non-distributed host can see', async ({ page }) => { + await page.route('**/api/features', route => + route.fulfill({ json: { distributed: false, agents: true, mcp: true } })) + await mockQuiet(page) + await page.goto('/app/operate') + const rail = page.locator('.console-rail') + await expect(rail.locator('a.nav-item[href="/app/backends"]')).toBeVisible() + // Gating is the thing most likely to break silently when items move group. + await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toHaveCount(0) + await expect(rail.locator('a.nav-item[href="/app/scheduling"]')).toHaveCount(0) + }) + + test('the sidebar keeps its operations badge', async ({ page }) => { + // Regression guard: this change edits the same config the badge reads, and + // the badge deliberately lives on the always-visible sidebar entry rather + // than the collapsible rail. + await mockQuiet(page, { + operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', progress: 40 }], + }) + await page.goto('/app') + await expect(page.locator('.sidebar-nav .nav-badge')).toBeVisible() + }) + + test('does not poll the summary away from Operate', async ({ page }) => { + let upgradeCalls = 0 + await page.route('**/api/backends/upgrades', route => { + upgradeCalls += 1 + route.fulfill({ json: NO_UPGRADES }) + }) + await page.route('**/api/operations', route => route.fulfill({ json: [] })) + await page.goto('/app/chat') + await expect(page.locator('.sidebar')).toBeVisible() + await page.waitForTimeout(1500) + // Nobody asked for this data outside Operate; a dashboard-shaped poll on + // every page is exactly what OperationsContext exists to avoid. + expect(upgradeCalls).toBe(0) + }) +}) + +test.describe('Operate overview headline', () => { + const SUMMARY = { + total: 18402, errors: 37, p95_ms: 842, window_hours: 24, + buckets: Array.from({ length: 12 }, (_, i) => ({ count: 100 + i * 10, errors: i })), + } + + test('reports counted totals rather than fetching the trace list', async ({ page }) => { + let listCalls = 0 + await page.route('**/api/traces?**', route => { listCalls += 1; route.fulfill({ json: [] }) }) + await page.route('**/api/traces/summary', route => route.fulfill({ json: SUMMARY })) + await mockQuiet(page) + await page.goto('/app/operate') + const headline = page.locator('.operate-headline') + await expect(headline).toBeVisible() + await expect(headline).toContainText('18,402') + await expect(headline).toContainText('37') + await expect(headline).toContainText('842') + // The whole point of the endpoint: three numbers, not the buffer. + expect(listCalls).toBe(0) + }) + + test('an installation that has served nothing says so instead of showing zeroes', async ({ page }) => { + await page.route('**/api/traces/summary', route => + route.fulfill({ json: { total: 0, errors: 0, p95_ms: 0, window_hours: 24, buckets: [] } })) + await mockQuiet(page) + await page.goto('/app/operate') + await expect(page.locator('.operate-headline')).toHaveCount(0) + }) +}) 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 404724b8c..c2cb31a78 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/account', 'Account'], ['/app/studio', 'Studio'], ['/app/manage', 'Manage'], + ['/app/operate', 'Operate overview'], ['/app/backends', 'Backends'], ['/app/activity', 'Activity'], ['/app/settings', 'Settings'], diff --git a/core/http/react-ui/e2e/studio-overview.spec.js b/core/http/react-ui/e2e/studio-overview.spec.js new file mode 100644 index 000000000..eecfe7f5a --- /dev/null +++ b/core/http/react-ui/e2e/studio-overview.spec.js @@ -0,0 +1,146 @@ +import { test, expect } from './coverage-fixtures.js' + +// Studio overview (src/pages/StudioOverview.jsx). +// +// Studio was a tab strip over six generators that opened on Images and told you +// nothing about what this machine could actually run. The tests are about that: +// what the strip reports before you click, and the difference between a +// modality that is switched off and one that merely has no model. + +const OVERVIEW = '[data-testid="studio-overview"]' +const MODALITY = '[data-testid="studio-modality"]' +const tabFor = (page, key) => page.locator(`.studio-tab[data-tab="${key}"]`) + +const model = (id, ...capabilities) => ({ id, capabilities }) + +// Images and speech covered, video and sound not. 3D and transform are feature +// flags rather than models, so they are controlled separately. +const SOME_MODELS = { + data: [ + model('flux.1-schnell', 'FLAG_IMAGE'), + model('kokoro-82m', 'FLAG_TTS'), + model('qwen3-8b', 'FLAG_CHAT'), + ], +} + +async function mockCapabilities(page, payload = SOME_MODELS) { + await page.route('**/api/models/capabilities', route => route.fulfill({ json: payload })) +} + +test.describe('Studio overview', () => { + test('Studio opens on the overview rather than dropping into Images', async ({ page }) => { + await mockCapabilities(page) + await page.goto('/app/studio') + await expect(page.locator(OVERVIEW)).toBeVisible() + }) + + test('an explicit tab still wins, so existing deep links keep working', async ({ page }) => { + await mockCapabilities(page) + await page.goto('/app/studio?tab=images') + await expect(page.locator(OVERVIEW)).toHaveCount(0) + await expect(page.locator('.media-layout')).toBeVisible() + }) + + test('an unrecognised tab falls back to the overview, not to Images', async ({ page }) => { + await mockCapabilities(page) + await page.goto('/app/studio?tab=nonsense') + await expect(page.locator(OVERVIEW)).toBeVisible() + }) + + test('the tab strip reports which modalities have a model', async ({ page }) => { + await mockCapabilities(page) + await page.goto('/app/studio') + // Filled: something installed advertises the capability. + await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible() + await expect(tabFor(page, 'tts').locator('.studio-tab__dot--on')).toBeVisible() + // Hollow: the modality is available, nothing serves it yet. + await expect(tabFor(page, 'video').locator('.studio-tab__dot--off')).toBeVisible() + await expect(tabFor(page, 'sound').locator('.studio-tab__dot--off')).toBeVisible() + }) + + test('a modality with no model offers a way to install one', async ({ page }) => { + await mockCapabilities(page) + await page.goto('/app/studio') + const video = page.locator(`${MODALITY}[data-modality="video"]`) + await expect(video).toBeVisible() + // The point of the lane: not a dead tab, a route to fixing it. + await expect(video.locator('a[href*="/app/models"]')).toBeVisible() + }) + + test('a modality with a model names it instead of offering an install', async ({ page }) => { + await mockCapabilities(page) + await page.goto('/app/studio') + const images = page.locator(`${MODALITY}[data-modality="images"]`) + await expect(images).toContainText('flux.1-schnell') + await expect(images.locator('a[href*="/app/models"]')).toHaveCount(0) + }) + + test('a disabled feature gets no tab and no lane at all', async ({ page }) => { + // Switched off is a different thing from "no model installed", and + // conflating them is how someone ends up staring at a control that cannot + // work. 3d is a permission rather than an /api/features entry, and + // hasFeature() short-circuits to true for admins and for auth-off + // installations, so withholding it needs a real non-admin session. + await page.route('**/api/auth/status', route => route.fulfill({ + json: { + authEnabled: true, + user: { name: 'someone', role: 'user', permissions: { images: true, video: true, tts: true, sound: true } }, + }, + })) + await mockCapabilities(page) + await page.goto('/app/studio') + await expect(page.locator(OVERVIEW)).toBeVisible() + await expect(tabFor(page, 'threed')).toHaveCount(0) + await expect(page.locator(`${MODALITY}[data-modality="threed"]`)).toHaveCount(0) + }) + + test('asks the capabilities endpoint once, not once per modality', async ({ page }) => { + let calls = 0 + await page.route('**/api/models/capabilities', route => { + calls += 1 + route.fulfill({ json: SOME_MODELS }) + }) + await page.goto('/app/studio') + await expect(page.locator(OVERVIEW)).toBeVisible() + await page.waitForTimeout(500) + // useModels() fetches the whole list and filters in the browser, so one + // hook per modality would be six identical requests on every mount. + expect(calls).toBe(1) + }) + + test('an installation with no models at all still renders every modality', async ({ page }) => { + await mockCapabilities(page, { data: [] }) + await page.goto('/app/studio') + await expect(page.locator(OVERVIEW)).toBeVisible() + await expect(page.locator(MODALITY).first()).toBeVisible() + await expect(page.locator('.studio-tab__dot--on')).toHaveCount(0) + }) + + test('recent outputs surface what was generated earlier', async ({ page }) => { + await mockCapabilities(page) + // History is localStorage, written by each generator. The overview is the + // first place it is read across modalities rather than within one. + await page.addInitScript(() => { + localStorage.setItem('localai_image_history', JSON.stringify([ + { id: 'i1', createdAt: Date.now(), model: 'flux.1-schnell', prompt: 'a brass orrery', elapsedMs: 6100 }, + ])) + }) + await page.goto('/app/studio') + const shelf = page.locator('[data-testid="studio-recent"]') + await expect(shelf).toBeVisible() + await expect(shelf).toContainText('flux.1-schnell') + }) + + test('no history means no empty shelf', async ({ page }) => { + await mockCapabilities(page) + await page.goto('/app/studio') + await expect(page.locator('[data-testid="studio-recent"]')).toHaveCount(0) + }) + + test('the overview is reachable back from a generator tab', async ({ page }) => { + await mockCapabilities(page) + await page.goto('/app/studio?tab=images') + await tabFor(page, 'overview').click() + await expect(page.locator(OVERVIEW)).toBeVisible() + }) +}) diff --git a/core/http/react-ui/e2e/studio-workbench.spec.js b/core/http/react-ui/e2e/studio-workbench.spec.js new file mode 100644 index 000000000..5cba688d6 --- /dev/null +++ b/core/http/react-ui/e2e/studio-workbench.spec.js @@ -0,0 +1,49 @@ +import { test, expect } from './coverage-fixtures.js' + +// The generator workbenches (mock 5b/5c): the control column and the record of +// what the form actually sent. + +test.describe('Studio workbench', () => { + test('the control column is a hairline field stack, not a shadowed card', async ({ page }) => { + await page.goto('/app/studio?tab=images') + const controls = page.locator('.media-controls') + await expect(controls).toBeVisible() + const style = await controls.evaluate(el => { + const cs = getComputedStyle(el) + return { shadow: cs.boxShadow, radius: cs.borderTopLeftRadius } + }) + expect(style.shadow).toBe('none') + expect(style.radius).toBe('0px') + }) + + test('fields are separated by a rule and labelled in caps', async ({ page }) => { + await page.goto('/app/studio?tab=images') + const label = page.locator('.media-controls .form-label').first() + await expect(label).toBeVisible() + const cs = await label.evaluate(el => getComputedStyle(el).textTransform) + expect(cs).toBe('uppercase') + }) + + test('no request is shown before one has been made', async ({ page }) => { + // A panel describing a request nobody sent is a tutorial, not a record. + await page.goto('/app/studio?tab=images') + await expect(page.locator('.request-panel')).toHaveCount(0) + }) + + test('generating records the request that was actually sent', async ({ page }) => { + await page.route('**/api/models/capabilities', route => + route.fulfill({ json: { data: [{ id: 'flux-mock', capabilities: ['FLAG_IMAGE'] }] } })) + await page.route('**/v1/images/generations', route => + route.fulfill({ json: { data: [{ url: 'https://example.invalid/a.png' }] } })) + + await page.goto('/app/studio?tab=images') + await page.locator('.media-controls textarea').first().fill('a brass orrery') + await page.getByRole('button', { name: /generate/i }).click() + + const panel = page.locator('.request-panel') + await expect(panel).toBeVisible() + await expect(panel).toContainText('/v1/images/generations') + await expect(panel).toContainText('a brass orrery') + await expect(panel.getByRole('button', { name: /curl/i })).toBeVisible() + }) +}) diff --git a/core/http/react-ui/e2e/theme-default.spec.js b/core/http/react-ui/e2e/theme-default.spec.js new file mode 100644 index 000000000..bad05bfb0 --- /dev/null +++ b/core/http/react-ui/e2e/theme-default.spec.js @@ -0,0 +1,15 @@ +import { test, expect } from './coverage-fixtures.js' + +test.describe('Theme default', () => { + test('a fresh install opens dark even when the OS prefers light', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto('/app') + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + }) + + test('a stored choice still wins', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('localai-theme', 'light')) + await page.goto('/app') + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + }) +}) diff --git a/core/http/react-ui/e2e/threed-gen.spec.js b/core/http/react-ui/e2e/threed-gen.spec.js index b10270e19..ac74481cb 100644 --- a/core/http/react-ui/e2e/threed-gen.spec.js +++ b/core/http/react-ui/e2e/threed-gen.spec.js @@ -278,7 +278,9 @@ test.describe('3D generation', () => { await page.goto('/app/studio?tab=threed') await expect(page.getByRole('button', { name: '3D', exact: true })).toHaveCount(0) - await expect(page.locator('.studio-tab', { hasText: 'Images' })).toHaveClass(/studio-tab-active/) + // Falls back to the overview rather than Images. Landing on Images was + // never a decision, only the first entry in the tab array. + await expect(page.locator('.studio-tab[data-tab="overview"]')).toHaveClass(/studio-tab-active/) await page.goto('/app/3d') await expect(page).toHaveURL(/\/app\/?$/) diff --git a/core/http/react-ui/public/locales/de/admin.json b/core/http/react-ui/public/locales/de/admin.json index 58becfd02..8a33cf67e 100644 --- a/core/http/react-ui/public/locales/de/admin.json +++ b/core/http/react-ui/public/locales/de/admin.json @@ -143,5 +143,35 @@ "explorer": { "title": "Explorer", "subtitle": "Dateien und Konfiguration durchsuchen" + }, + "operate": { + "overview": { + "title": "Overview", + "subtitle": "Everything running on this installation, and anything that wants a decision.", + "attention": { + "heading": "Needs attention", + "clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.", + "backendUpdate": "Update available: {{from}} → {{to}}" + }, + "sections": { + "heading": "Sections", + "runtime": "Runtime", + "runtimeSummary": "{{updates}} backend updates · {{running}} operations running", + "cluster": "Cluster", + "clusterSummary": "{{nodes}} nodes", + "observability": "Observability", + "observabilitySummary": "Usage and traces", + "administration": "Administration", + "administrationSummary": "Users, middleware, host and settings", + "clusterSingle": "Single node", + "observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms" + }, + "headline": { + "requests": "Requests · {{hours}}h", + "errors": "Failed requests", + "p95": "p95 latency", + "quiet": "Nothing has been served yet. Totals appear once requests start arriving." + } + } } } diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index f686fae2f..76cd0557b 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -118,5 +118,8 @@ "newChat": "Neuer Chat", "clearAll": "Alle löschen", "deleteAllTitle": "Alle Unterhaltungen löschen" + }, + "message": { + "you": "You" } } diff --git a/core/http/react-ui/public/locales/de/home.json b/core/http/react-ui/public/locales/de/home.json index d4f438bfb..a73461ed4 100644 --- a/core/http/react-ui/public/locales/de/home.json +++ b/core/http/react-ui/public/locales/de/home.json @@ -17,7 +17,9 @@ "modelsLoaded_other": "{{count}} models loaded", "noModelsLoaded": "No models loaded", "nodes_one": "{{count}} node", - "nodes_other": "{{count}} nodes" + "nodes_other": "{{count}} nodes", + "loadedLabel": "Loaded", + "nodesLabel": "Nodes" }, "assistant": { "title": "LocalAI per Chat verwalten", @@ -47,7 +49,8 @@ "count_one": "{{count}} Modell geladen", "count_other": "{{count}} Modelle geladen", "stop": "Modell stoppen", - "stopAll": "Alle stoppen" + "stopAll": "Alle stoppen", + "serving": "Serving" }, "stopDialog": { "title": "Modell stoppen", diff --git a/core/http/react-ui/public/locales/de/media.json b/core/http/react-ui/public/locales/de/media.json index 096c74b36..d74205e68 100644 --- a/core/http/react-ui/public/locales/de/media.json +++ b/core/http/react-ui/public/locales/de/media.json @@ -5,6 +5,32 @@ "video": "Video", "tts": "TTS", "sound": "Audio", + "transform": "Transform", + "overview": "Overview" + }, + "overview": { + "eyebrow": "{{ready}} of {{total}} modalities ready", + "title": "Studio", + "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.", + "canMake": "What you can make", + "running": "Running now", + "recent": "Recent outputs", + "noModel": "No model installed", + "install": "Install a model", + "ready": "Ready", + "seconds": "{{seconds}}s", + "describe": { + "images": "Text to image, image to image, reference images", + "video": "Text to video and image to video", + "threed": "Image to mesh reconstruction", + "tts": "Text to speech using your voice library", + "sound": "Music and sound effects from a prompt", + "transform": "Separation, enhancement and voice conversion" + } + }, + "groups": { + "create": "Create", + "voice": "Voice", "transform": "Transform" } }, @@ -157,5 +183,10 @@ "clearMessage": "Alle Verlaufseinträge entfernen? Diese Aktion kann nicht rückgängig gemacht werden.", "clearConfirm": "Löschen", "cleared": "Verlauf gelöscht" + }, + "request": { + "heading": "Request", + "copyCurl": "Copy as curl", + "copied": "Copied" } } diff --git a/core/http/react-ui/public/locales/de/nav.json b/core/http/react-ui/public/locales/de/nav.json index a2e8de1a8..aba9ebeff 100644 --- a/core/http/react-ui/public/locales/de/nav.json +++ b/core/http/react-ui/public/locales/de/nav.json @@ -24,7 +24,9 @@ "observability": "Observability", "access": "Access", "system": "System", - "activity": "Activity" + "activity": "Activity", + "runtime": "Laufzeit", + "administration": "Verwaltung" }, "items": { "home": "Start", @@ -57,7 +59,8 @@ "settings": "Einstellungen", "api": "API", "middleware": "Middleware", - "activity": "Aktivität" + "activity": "Aktivität", + "overview": "Übersicht" }, "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 f6dccf80b..f5c826cb3 100644 --- a/core/http/react-ui/public/locales/en/admin.json +++ b/core/http/react-ui/public/locales/en/admin.json @@ -166,5 +166,35 @@ "explorer": { "title": "Explorer", "subtitle": "Browse files and configuration" + }, + "operate": { + "overview": { + "title": "Overview", + "subtitle": "Everything running on this installation, and anything that wants a decision.", + "attention": { + "heading": "Needs attention", + "clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.", + "backendUpdate": "Update available: {{from}} → {{to}}" + }, + "sections": { + "heading": "Sections", + "runtime": "Runtime", + "runtimeSummary": "{{updates}} backend updates · {{running}} operations running", + "cluster": "Cluster", + "clusterSummary": "{{nodes}} nodes", + "observability": "Observability", + "observabilitySummary": "Usage and traces", + "administration": "Administration", + "administrationSummary": "Users, middleware, host and settings", + "clusterSingle": "Single node", + "observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms" + }, + "headline": { + "requests": "Requests · {{hours}}h", + "errors": "Failed requests", + "p95": "p95 latency", + "quiet": "Nothing has been served yet. Totals appear once requests start arriving." + } + } } } diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index fd2cee84d..0e7af136b 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -124,5 +124,8 @@ "newChat": "New chat", "clearAll": "Clear all", "deleteAllTitle": "Delete all conversations" + }, + "message": { + "you": "You" } } diff --git a/core/http/react-ui/public/locales/en/home.json b/core/http/react-ui/public/locales/en/home.json index 35533a5a8..c85a94522 100644 --- a/core/http/react-ui/public/locales/en/home.json +++ b/core/http/react-ui/public/locales/en/home.json @@ -17,7 +17,9 @@ "modelsLoaded_other": "{{count}} models loaded", "noModelsLoaded": "No models loaded", "nodes_one": "{{count}} node", - "nodes_other": "{{count}} nodes" + "nodes_other": "{{count}} nodes", + "loadedLabel": "Loaded", + "nodesLabel": "Nodes" }, "assistant": { "title": "Manage LocalAI by chatting", @@ -47,7 +49,8 @@ "count_one": "{{count}} model loaded", "count_other": "{{count}} models loaded", "stop": "Stop model", - "stopAll": "Stop all" + "stopAll": "Stop all", + "serving": "Serving" }, "stopDialog": { "title": "Stop Model", diff --git a/core/http/react-ui/public/locales/en/media.json b/core/http/react-ui/public/locales/en/media.json index d3e9484f2..1b392d9d5 100644 --- a/core/http/react-ui/public/locales/en/media.json +++ b/core/http/react-ui/public/locales/en/media.json @@ -6,7 +6,33 @@ "tts": "TTS", "sound": "Sound", "transform": "Transform", - "threed": "3D" + "threed": "3D", + "overview": "Overview" + }, + "overview": { + "eyebrow": "{{ready}} of {{total}} modalities ready", + "title": "Studio", + "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.", + "canMake": "What you can make", + "running": "Running now", + "recent": "Recent outputs", + "noModel": "No model installed", + "install": "Install a model", + "ready": "Ready", + "seconds": "{{seconds}}s", + "describe": { + "images": "Text to image, image to image, reference images", + "video": "Text to video and image to video", + "threed": "Image to mesh reconstruction", + "tts": "Text to speech using your voice library", + "sound": "Music and sound effects from a prompt", + "transform": "Separation, enhancement and voice conversion" + } + }, + "groups": { + "create": "Create", + "voice": "Voice", + "transform": "Transform" } }, "image": { @@ -426,5 +452,10 @@ "clearMessage": "Remove all history entries? This cannot be undone.", "clearConfirm": "Clear", "cleared": "History cleared" + }, + "request": { + "heading": "Request", + "copyCurl": "Copy as curl", + "copied": "Copied" } } diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index 036e6b33d..a583abea6 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -24,7 +24,9 @@ "observability": "Observability", "access": "Access", "system": "System", - "activity": "Activity" + "activity": "Activity", + "runtime": "Runtime", + "administration": "Administration" }, "items": { "home": "Home", @@ -58,7 +60,8 @@ "system": "System", "settings": "Settings", "api": "API", - "activity": "Activity" + "activity": "Activity", + "overview": "Overview" }, "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 b1183b24e..57cb55e27 100644 --- a/core/http/react-ui/public/locales/es/admin.json +++ b/core/http/react-ui/public/locales/es/admin.json @@ -143,5 +143,35 @@ "explorer": { "title": "Explorador", "subtitle": "Explora archivos y configuración" + }, + "operate": { + "overview": { + "title": "Overview", + "subtitle": "Everything running on this installation, and anything that wants a decision.", + "attention": { + "heading": "Needs attention", + "clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.", + "backendUpdate": "Update available: {{from}} → {{to}}" + }, + "sections": { + "heading": "Sections", + "runtime": "Runtime", + "runtimeSummary": "{{updates}} backend updates · {{running}} operations running", + "cluster": "Cluster", + "clusterSummary": "{{nodes}} nodes", + "observability": "Observability", + "observabilitySummary": "Usage and traces", + "administration": "Administration", + "administrationSummary": "Users, middleware, host and settings", + "clusterSingle": "Single node", + "observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms" + }, + "headline": { + "requests": "Requests · {{hours}}h", + "errors": "Failed requests", + "p95": "p95 latency", + "quiet": "Nothing has been served yet. Totals appear once requests start arriving." + } + } } } diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index cec9d9610..0b551f045 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -118,5 +118,8 @@ "newChat": "Nuevo chat", "clearAll": "Borrar todo", "deleteAllTitle": "Eliminar todas las conversaciones" + }, + "message": { + "you": "You" } } diff --git a/core/http/react-ui/public/locales/es/home.json b/core/http/react-ui/public/locales/es/home.json index 83d29c1a0..ecd486901 100644 --- a/core/http/react-ui/public/locales/es/home.json +++ b/core/http/react-ui/public/locales/es/home.json @@ -17,7 +17,9 @@ "modelsLoaded_other": "{{count}} models loaded", "noModelsLoaded": "No models loaded", "nodes_one": "{{count}} node", - "nodes_other": "{{count}} nodes" + "nodes_other": "{{count}} nodes", + "loadedLabel": "Loaded", + "nodesLabel": "Nodes" }, "assistant": { "title": "Administra LocalAI chateando", @@ -47,7 +49,8 @@ "count_one": "{{count}} modelo cargado", "count_other": "{{count}} modelos cargados", "stop": "Detener modelo", - "stopAll": "Detener todos" + "stopAll": "Detener todos", + "serving": "Serving" }, "stopDialog": { "title": "Detener modelo", diff --git a/core/http/react-ui/public/locales/es/media.json b/core/http/react-ui/public/locales/es/media.json index 1a36a3a24..9680f5729 100644 --- a/core/http/react-ui/public/locales/es/media.json +++ b/core/http/react-ui/public/locales/es/media.json @@ -5,6 +5,32 @@ "video": "Video", "tts": "TTS", "sound": "Sonido", + "transform": "Transform", + "overview": "Overview" + }, + "overview": { + "eyebrow": "{{ready}} of {{total}} modalities ready", + "title": "Studio", + "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.", + "canMake": "What you can make", + "running": "Running now", + "recent": "Recent outputs", + "noModel": "No model installed", + "install": "Install a model", + "ready": "Ready", + "seconds": "{{seconds}}s", + "describe": { + "images": "Text to image, image to image, reference images", + "video": "Text to video and image to video", + "threed": "Image to mesh reconstruction", + "tts": "Text to speech using your voice library", + "sound": "Music and sound effects from a prompt", + "transform": "Separation, enhancement and voice conversion" + } + }, + "groups": { + "create": "Create", + "voice": "Voice", "transform": "Transform" } }, @@ -157,5 +183,10 @@ "clearMessage": "¿Eliminar todas las entradas del historial? Esto no se puede deshacer.", "clearConfirm": "Borrar", "cleared": "Historial borrado" + }, + "request": { + "heading": "Request", + "copyCurl": "Copy as curl", + "copied": "Copied" } } diff --git a/core/http/react-ui/public/locales/es/nav.json b/core/http/react-ui/public/locales/es/nav.json index f6361c041..78794b526 100644 --- a/core/http/react-ui/public/locales/es/nav.json +++ b/core/http/react-ui/public/locales/es/nav.json @@ -24,7 +24,9 @@ "observability": "Observability", "access": "Access", "system": "System", - "activity": "Activity" + "activity": "Activity", + "runtime": "Runtime", + "administration": "Administración" }, "items": { "home": "Inicio", @@ -57,7 +59,8 @@ "settings": "Configuración", "api": "API", "middleware": "Middleware", - "activity": "Actividad" + "activity": "Actividad", + "overview": "Resumen" }, "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 7a11b0677..ef25fdc9c 100644 --- a/core/http/react-ui/public/locales/id/admin.json +++ b/core/http/react-ui/public/locales/id/admin.json @@ -166,5 +166,35 @@ "explorer": { "title": "Penjelajah", "subtitle": "Jelajahi file dan konfigurasi" + }, + "operate": { + "overview": { + "title": "Overview", + "subtitle": "Everything running on this installation, and anything that wants a decision.", + "attention": { + "heading": "Needs attention", + "clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.", + "backendUpdate": "Update available: {{from}} → {{to}}" + }, + "sections": { + "heading": "Sections", + "runtime": "Runtime", + "runtimeSummary": "{{updates}} backend updates · {{running}} operations running", + "cluster": "Cluster", + "clusterSummary": "{{nodes}} nodes", + "observability": "Observability", + "observabilitySummary": "Usage and traces", + "administration": "Administration", + "administrationSummary": "Users, middleware, host and settings", + "clusterSingle": "Single node", + "observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms" + }, + "headline": { + "requests": "Requests · {{hours}}h", + "errors": "Failed requests", + "p95": "p95 latency", + "quiet": "Nothing has been served yet. Totals appear once requests start arriving." + } + } } } diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json index 8d2834c3b..f647946e7 100644 --- a/core/http/react-ui/public/locales/id/chat.json +++ b/core/http/react-ui/public/locales/id/chat.json @@ -118,5 +118,8 @@ "newChat": "Obrolan baru", "clearAll": "Hapus semua", "deleteAllTitle": "Hapus semua percakapan" + }, + "message": { + "you": "You" } } diff --git a/core/http/react-ui/public/locales/id/home.json b/core/http/react-ui/public/locales/id/home.json index 4e2aafdcb..fffd3b484 100644 --- a/core/http/react-ui/public/locales/id/home.json +++ b/core/http/react-ui/public/locales/id/home.json @@ -17,7 +17,9 @@ "modelsLoaded_other": "{{count}} model dimuat", "noModelsLoaded": "Tidak ada model yang dimuat", "nodes_one": "{{count}} node", - "nodes_other": "{{count}} nodes" + "nodes_other": "{{count}} nodes", + "loadedLabel": "Loaded", + "nodesLabel": "Nodes" }, "assistant": { "title": "Kelola LocalAI melalui obrolan", @@ -47,7 +49,8 @@ "count_one": "{{count}} model dimuat", "count_other": "{{count}} model dimuat", "stop": "Hentikan model", - "stopAll": "Hentikan semua" + "stopAll": "Hentikan semua", + "serving": "Serving" }, "stopDialog": { "title": "Hentikan Model", diff --git a/core/http/react-ui/public/locales/id/media.json b/core/http/react-ui/public/locales/id/media.json index b6b644fda..34f3efc50 100644 --- a/core/http/react-ui/public/locales/id/media.json +++ b/core/http/react-ui/public/locales/id/media.json @@ -5,7 +5,33 @@ "video": "Video", "tts": "TTS", "sound": "Suara", - "transform": "Transformasi" + "transform": "Transformasi", + "overview": "Overview" + }, + "overview": { + "eyebrow": "{{ready}} of {{total}} modalities ready", + "title": "Studio", + "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.", + "canMake": "What you can make", + "running": "Running now", + "recent": "Recent outputs", + "noModel": "No model installed", + "install": "Install a model", + "ready": "Ready", + "seconds": "{{seconds}}s", + "describe": { + "images": "Text to image, image to image, reference images", + "video": "Text to video and image to video", + "threed": "Image to mesh reconstruction", + "tts": "Text to speech using your voice library", + "sound": "Music and sound effects from a prompt", + "transform": "Separation, enhancement and voice conversion" + } + }, + "groups": { + "create": "Create", + "voice": "Voice", + "transform": "Transform" } }, "image": { @@ -204,5 +230,10 @@ "clearMessage": "Hapus semua entri riwayat? Tindakan ini tidak dapat dibatalkan.", "clearConfirm": "Hapus", "cleared": "Riwayat dihapus" + }, + "request": { + "heading": "Request", + "copyCurl": "Copy as curl", + "copied": "Copied" } } diff --git a/core/http/react-ui/public/locales/id/nav.json b/core/http/react-ui/public/locales/id/nav.json index 59a930a1c..4071d8872 100644 --- a/core/http/react-ui/public/locales/id/nav.json +++ b/core/http/react-ui/public/locales/id/nav.json @@ -24,7 +24,9 @@ "observability": "Observabilitas", "access": "Akses", "system": "Sistem", - "activity": "Activity" + "activity": "Activity", + "runtime": "Runtime", + "administration": "Administrasi" }, "items": { "home": "Beranda", @@ -57,7 +59,8 @@ "system": "Sistem", "settings": "Pengaturan", "api": "API", - "activity": "Aktivitas" + "activity": "Aktivitas", + "overview": "Ikhtisar" }, "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 79b185638..0574fd7a3 100644 --- a/core/http/react-ui/public/locales/it/admin.json +++ b/core/http/react-ui/public/locales/it/admin.json @@ -143,5 +143,35 @@ "explorer": { "title": "Esplora risorse", "subtitle": "Sfoglia file e configurazioni" + }, + "operate": { + "overview": { + "title": "Overview", + "subtitle": "Everything running on this installation, and anything that wants a decision.", + "attention": { + "heading": "Needs attention", + "clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.", + "backendUpdate": "Update available: {{from}} → {{to}}" + }, + "sections": { + "heading": "Sections", + "runtime": "Runtime", + "runtimeSummary": "{{updates}} backend updates · {{running}} operations running", + "cluster": "Cluster", + "clusterSummary": "{{nodes}} nodes", + "observability": "Observability", + "observabilitySummary": "Usage and traces", + "administration": "Administration", + "administrationSummary": "Users, middleware, host and settings", + "clusterSingle": "Single node", + "observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms" + }, + "headline": { + "requests": "Requests · {{hours}}h", + "errors": "Failed requests", + "p95": "p95 latency", + "quiet": "Nothing has been served yet. Totals appear once requests start arriving." + } + } } } diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index e95d18c8b..6c65b1c1d 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -118,5 +118,8 @@ "newChat": "Nuova chat", "clearAll": "Cancella tutto", "deleteAllTitle": "Elimina tutte le conversazioni" + }, + "message": { + "you": "You" } } diff --git a/core/http/react-ui/public/locales/it/home.json b/core/http/react-ui/public/locales/it/home.json index e6f46fb30..74dc8e7b9 100644 --- a/core/http/react-ui/public/locales/it/home.json +++ b/core/http/react-ui/public/locales/it/home.json @@ -17,7 +17,9 @@ "modelsLoaded_other": "{{count}} modelli caricati", "noModelsLoaded": "Nessun modello caricato", "nodes_one": "{{count}} nodo", - "nodes_other": "{{count}} nodi" + "nodes_other": "{{count}} nodi", + "loadedLabel": "Loaded", + "nodesLabel": "Nodes" }, "assistant": { "title": "Gestisci LocalAI chattando", @@ -47,7 +49,8 @@ "count_one": "{{count}} modello caricato", "count_other": "{{count}} modelli caricati", "stop": "Ferma modello", - "stopAll": "Ferma tutti" + "stopAll": "Ferma tutti", + "serving": "Serving" }, "stopDialog": { "title": "Ferma modello", diff --git a/core/http/react-ui/public/locales/it/media.json b/core/http/react-ui/public/locales/it/media.json index b41629a1c..2dd8be984 100644 --- a/core/http/react-ui/public/locales/it/media.json +++ b/core/http/react-ui/public/locales/it/media.json @@ -5,6 +5,32 @@ "video": "Video", "tts": "TTS", "sound": "Audio", + "transform": "Transform", + "overview": "Overview" + }, + "overview": { + "eyebrow": "{{ready}} of {{total}} modalities ready", + "title": "Studio", + "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.", + "canMake": "What you can make", + "running": "Running now", + "recent": "Recent outputs", + "noModel": "No model installed", + "install": "Install a model", + "ready": "Ready", + "seconds": "{{seconds}}s", + "describe": { + "images": "Text to image, image to image, reference images", + "video": "Text to video and image to video", + "threed": "Image to mesh reconstruction", + "tts": "Text to speech using your voice library", + "sound": "Music and sound effects from a prompt", + "transform": "Separation, enhancement and voice conversion" + } + }, + "groups": { + "create": "Create", + "voice": "Voice", "transform": "Transform" } }, @@ -157,5 +183,10 @@ "clearMessage": "Rimuovere tutte le voci della cronologia? Questa azione non può essere annullata.", "clearConfirm": "Cancella", "cleared": "Cronologia cancellata" + }, + "request": { + "heading": "Request", + "copyCurl": "Copy as curl", + "copied": "Copied" } } diff --git a/core/http/react-ui/public/locales/it/nav.json b/core/http/react-ui/public/locales/it/nav.json index d7c60e6bb..910dc45a9 100644 --- a/core/http/react-ui/public/locales/it/nav.json +++ b/core/http/react-ui/public/locales/it/nav.json @@ -24,7 +24,9 @@ "observability": "Observability", "access": "Access", "system": "System", - "activity": "Activity" + "activity": "Activity", + "runtime": "Runtime", + "administration": "Amministrazione" }, "items": { "home": "Home", @@ -57,7 +59,8 @@ "settings": "Impostazioni", "api": "API", "middleware": "Middleware", - "activity": "Attività" + "activity": "Attività", + "overview": "Panoramica" }, "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 b727ba4c4..ca5a53554 100644 --- a/core/http/react-ui/public/locales/ko/admin.json +++ b/core/http/react-ui/public/locales/ko/admin.json @@ -166,5 +166,35 @@ "explorer": { "title": "탐색기", "subtitle": "파일과 구성을 둘러봅니다" + }, + "operate": { + "overview": { + "title": "Overview", + "subtitle": "Everything running on this installation, and anything that wants a decision.", + "attention": { + "heading": "Needs attention", + "clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.", + "backendUpdate": "Update available: {{from}} → {{to}}" + }, + "sections": { + "heading": "Sections", + "runtime": "Runtime", + "runtimeSummary": "{{updates}} backend updates · {{running}} operations running", + "cluster": "Cluster", + "clusterSummary": "{{nodes}} nodes", + "observability": "Observability", + "observabilitySummary": "Usage and traces", + "administration": "Administration", + "administrationSummary": "Users, middleware, host and settings", + "clusterSingle": "Single node", + "observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms" + }, + "headline": { + "requests": "Requests · {{hours}}h", + "errors": "Failed requests", + "p95": "p95 latency", + "quiet": "Nothing has been served yet. Totals appear once requests start arriving." + } + } } } diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json index 522f844e6..8221d764e 100644 --- a/core/http/react-ui/public/locales/ko/chat.json +++ b/core/http/react-ui/public/locales/ko/chat.json @@ -118,5 +118,8 @@ "newChat": "새 채팅", "clearAll": "모두 지우기", "deleteAllTitle": "모든 대화 삭제" + }, + "message": { + "you": "You" } } diff --git a/core/http/react-ui/public/locales/ko/home.json b/core/http/react-ui/public/locales/ko/home.json index b54572697..4b9f15f36 100644 --- a/core/http/react-ui/public/locales/ko/home.json +++ b/core/http/react-ui/public/locales/ko/home.json @@ -17,7 +17,9 @@ "modelsLoaded_other": "{{count}} models loaded", "noModelsLoaded": "No models loaded", "nodes_one": "{{count}} node", - "nodes_other": "{{count}} nodes" + "nodes_other": "{{count}} nodes", + "loadedLabel": "Loaded", + "nodesLabel": "Nodes" }, "assistant": { "title": "채팅으로 LocalAI 관리", @@ -47,7 +49,8 @@ "count_one": "모델 {{count}}개 로드됨", "count_other": "모델 {{count}}개 로드됨", "stop": "모델 중지", - "stopAll": "모두 중지" + "stopAll": "모두 중지", + "serving": "Serving" }, "stopDialog": { "title": "모델 중지", diff --git a/core/http/react-ui/public/locales/ko/media.json b/core/http/react-ui/public/locales/ko/media.json index a18abc0eb..30f8fd1c1 100644 --- a/core/http/react-ui/public/locales/ko/media.json +++ b/core/http/react-ui/public/locales/ko/media.json @@ -5,6 +5,32 @@ "video": "비디오", "tts": "TTS", "sound": "사운드", + "transform": "Transform", + "overview": "Overview" + }, + "overview": { + "eyebrow": "{{ready}} of {{total}} modalities ready", + "title": "Studio", + "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.", + "canMake": "What you can make", + "running": "Running now", + "recent": "Recent outputs", + "noModel": "No model installed", + "install": "Install a model", + "ready": "Ready", + "seconds": "{{seconds}}s", + "describe": { + "images": "Text to image, image to image, reference images", + "video": "Text to video and image to video", + "threed": "Image to mesh reconstruction", + "tts": "Text to speech using your voice library", + "sound": "Music and sound effects from a prompt", + "transform": "Separation, enhancement and voice conversion" + } + }, + "groups": { + "create": "Create", + "voice": "Voice", "transform": "Transform" } }, @@ -157,5 +183,10 @@ "clearMessage": "모든 기록 항목을 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "clearConfirm": "지우기", "cleared": "기록이 지워졌습니다" + }, + "request": { + "heading": "Request", + "copyCurl": "Copy as curl", + "copied": "Copied" } } diff --git a/core/http/react-ui/public/locales/ko/nav.json b/core/http/react-ui/public/locales/ko/nav.json index 6f9e1f8d9..8186bb06b 100644 --- a/core/http/react-ui/public/locales/ko/nav.json +++ b/core/http/react-ui/public/locales/ko/nav.json @@ -24,7 +24,9 @@ "observability": "Observability", "access": "Access", "system": "System", - "activity": "Activity" + "activity": "Activity", + "runtime": "런타임", + "administration": "관리" }, "items": { "home": "홈", @@ -57,7 +59,8 @@ "system": "시스템", "settings": "설정", "api": "API", - "activity": "활동" + "activity": "활동", + "overview": "개요" }, "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 43b6b10f7..ed98dd866 100644 --- a/core/http/react-ui/public/locales/zh-CN/admin.json +++ b/core/http/react-ui/public/locales/zh-CN/admin.json @@ -143,5 +143,35 @@ "explorer": { "title": "资源浏览器", "subtitle": "浏览文件和配置" + }, + "operate": { + "overview": { + "title": "Overview", + "subtitle": "Everything running on this installation, and anything that wants a decision.", + "attention": { + "heading": "Needs attention", + "clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.", + "backendUpdate": "Update available: {{from}} → {{to}}" + }, + "sections": { + "heading": "Sections", + "runtime": "Runtime", + "runtimeSummary": "{{updates}} backend updates · {{running}} operations running", + "cluster": "Cluster", + "clusterSummary": "{{nodes}} nodes", + "observability": "Observability", + "observabilitySummary": "Usage and traces", + "administration": "Administration", + "administrationSummary": "Users, middleware, host and settings", + "clusterSingle": "Single node", + "observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms" + }, + "headline": { + "requests": "Requests · {{hours}}h", + "errors": "Failed requests", + "p95": "p95 latency", + "quiet": "Nothing has been served yet. Totals appear once requests start arriving." + } + } } } diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json index 38694e40a..3708f952e 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -118,5 +118,8 @@ "newChat": "新对话", "clearAll": "清除全部", "deleteAllTitle": "删除所有对话" + }, + "message": { + "you": "You" } } diff --git a/core/http/react-ui/public/locales/zh-CN/home.json b/core/http/react-ui/public/locales/zh-CN/home.json index 4386c293e..47b157a90 100644 --- a/core/http/react-ui/public/locales/zh-CN/home.json +++ b/core/http/react-ui/public/locales/zh-CN/home.json @@ -17,7 +17,9 @@ "modelsLoaded_other": "{{count}} models loaded", "noModelsLoaded": "No models loaded", "nodes_one": "{{count}} node", - "nodes_other": "{{count}} nodes" + "nodes_other": "{{count}} nodes", + "loadedLabel": "Loaded", + "nodesLabel": "Nodes" }, "assistant": { "title": "通过聊天管理 LocalAI", @@ -47,7 +49,8 @@ "count_one": "已加载 {{count}} 个模型", "count_other": "已加载 {{count}} 个模型", "stop": "停止模型", - "stopAll": "全部停止" + "stopAll": "全部停止", + "serving": "Serving" }, "stopDialog": { "title": "停止模型", diff --git a/core/http/react-ui/public/locales/zh-CN/media.json b/core/http/react-ui/public/locales/zh-CN/media.json index f395cc193..6c1dac331 100644 --- a/core/http/react-ui/public/locales/zh-CN/media.json +++ b/core/http/react-ui/public/locales/zh-CN/media.json @@ -5,6 +5,32 @@ "video": "视频", "tts": "TTS", "sound": "声音", + "transform": "Transform", + "overview": "Overview" + }, + "overview": { + "eyebrow": "{{ready}} of {{total}} modalities ready", + "title": "Studio", + "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.", + "canMake": "What you can make", + "running": "Running now", + "recent": "Recent outputs", + "noModel": "No model installed", + "install": "Install a model", + "ready": "Ready", + "seconds": "{{seconds}}s", + "describe": { + "images": "Text to image, image to image, reference images", + "video": "Text to video and image to video", + "threed": "Image to mesh reconstruction", + "tts": "Text to speech using your voice library", + "sound": "Music and sound effects from a prompt", + "transform": "Separation, enhancement and voice conversion" + } + }, + "groups": { + "create": "Create", + "voice": "Voice", "transform": "Transform" } }, @@ -157,5 +183,10 @@ "clearMessage": "删除所有历史条目?此操作无法撤销。", "clearConfirm": "清除", "cleared": "历史已清除" + }, + "request": { + "heading": "Request", + "copyCurl": "Copy as curl", + "copied": "Copied" } } 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 128210fc6..265097192 100644 --- a/core/http/react-ui/public/locales/zh-CN/nav.json +++ b/core/http/react-ui/public/locales/zh-CN/nav.json @@ -24,7 +24,9 @@ "observability": "Observability", "access": "Access", "system": "System", - "activity": "Activity" + "activity": "Activity", + "runtime": "运行时", + "administration": "管理" }, "items": { "home": "首页", @@ -57,7 +59,8 @@ "settings": "设置", "api": "API", "middleware": "Middleware", - "activity": "活动" + "activity": "活动", + "overview": "概览" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 5033b4d0c..00ee04765 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -51,18 +51,32 @@ } /* Footer */ +/* One line, not three stacked centred rows. The footer is chrome: it should + cost a line of height and be findable, not occupy the bottom sixth of every + page. Version left, links right, copyright folded in beside the version. */ .app-footer { background: transparent; border-top: 1px solid var(--color-border-divider); - padding: var(--spacing-md) var(--spacing-lg); + padding: var(--spacing-sm) var(--spacing-lg); margin-top: auto; } .app-footer-inner { display: flex; - flex-direction: column; + flex-wrap: wrap; align-items: center; - gap: var(--spacing-sm); + justify-content: space-between; + gap: var(--spacing-xs) var(--spacing-md); +} + +/* Links push right on a wide viewport and wrap under on a narrow one. */ +.app-footer-links { margin-left: auto; } + +/* Below the fold of a phone the three groups stack anyway; centre them so the + wrap does not read as a ragged left column. */ +@media (max-width: 560px) { + .app-footer-inner { justify-content: center; } + .app-footer-links { margin-left: 0; } } .app-footer-version { @@ -537,6 +551,9 @@ justify-content: center; flex-direction: column; gap: var(--spacing-xs); + /* The controls stack, but the row's inline padding still left them wider + than the collapsed rail, so their edges were clipped. */ + padding-inline: 2px; } .sidebar.collapsed .theme-toggle { @@ -1219,14 +1236,20 @@ margin-bottom: var(--spacing-xl); } +/* The editorial title scale from the site: larger, set tighter, and on a line + height near 1 so a two-word title reads as a statement rather than a label. + The typeface is unchanged — DESIGN.md keeps the existing type system — so the + whole difference is scale, tracking and leading, which is where the site gets + its voice from in the first place. */ .page-title { font-family: var(--font-sans); - font-size: clamp(1.5rem, 1.15rem + 1.4vw, var(--text-3xl)); + font-size: clamp(1.875rem, 1.3rem + 2.2vw, 3rem); font-weight: var(--font-weight-semibold); - letter-spacing: -0.018em; - line-height: var(--leading-tight); + letter-spacing: -0.04em; + line-height: 1.02; margin-bottom: var(--spacing-xs); color: var(--color-text-primary); + text-wrap: balance; } /* Mid hierarchy tier — between page title and the xs uppercase group labels */ @@ -2125,7 +2148,12 @@ select.input { Left accent bar ties the color to the metric's semantic (success/warning/ error/primary), icon chip sits top-right, value is left-aligned and prominent so you can scan a row of cards without reading labels. */ -.stat-grid { +/* Renamed off `.stat-grid` in favour of the children it actually holds. The + split views introduced a second, unrelated `.stat-grid` for detail panes + further down this file, and being later it won every shared property: these + cards were being laid out on that component's 120px columns and 1px hairline + gap, which crushed their labels at every width. */ +.stat-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--spacing-md); @@ -3355,14 +3383,22 @@ button.collapsible-header:focus-visible { .chat-message { display: flex; gap: var(--spacing-sm); - max-width: 88%; + align-self: stretch; + max-width: 100%; min-width: 0; + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--color-border-divider); animation: messageSlideIn 250ms ease-out; } +/* A transcript, not bubbles. Rounded filled asymmetric bubbles fight a system + built on hairlines, and they carry the speaker in shape and side rather than + in words — which is exactly what a mono role label does better, and what + survives being read aloud or printed. Both roles now run full width down one + column, separated by a rule. */ .chat-message-user { - align-self: flex-end; - flex-direction: row-reverse; + align-self: stretch; + flex-direction: row; } .chat-message-assistant { @@ -3385,11 +3421,9 @@ button.collapsible-header:focus-visible { color: var(--color-primary-text); } -/* Assistant gets the left-border accent on the bubble; the avatar is - visual noise once that accent is in place. */ -.chat-message-assistant .chat-message-avatar { - display: none; -} +/* The left-edge accent plus the role label carry the speaker, so the avatar is + noise for both roles now that neither side has a bubble. */ +.chat-message-avatar { display: none; } .chat-message-bubble { display: flex; @@ -3420,13 +3454,16 @@ button.collapsible-header:focus-visible { color: var(--color-text-primary); } +/* Same treatment as the assistant, in the action tone so the two are still + told apart at a glance without a fill or a corner radius. */ .chat-message-user .chat-message-content { - background: var(--color-primary-light); + background: transparent; color: var(--color-text-primary); - border: 1px solid var(--color-primary-border); - border-radius: 16px 4px 16px 16px; - padding: var(--spacing-sm) var(--spacing-md); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + border: none; + border-left: 2px solid var(--color-primary); + border-radius: 0; + padding: 0 var(--spacing-md); + box-shadow: none; } .chat-message-content pre { @@ -5081,6 +5118,7 @@ button.collapsible-header:focus-visible { /* Studio tabs */ .studio-tabs { display: flex; + flex-wrap: wrap; gap: var(--spacing-xs); border-bottom: 1px solid var(--color-border-subtle); padding: var(--spacing-sm) var(--spacing-xl) 0; @@ -5135,17 +5173,43 @@ button.collapsible-header:focus-visible { } } +/* The workbench control column. A hairline field stack rather than a shadowed + card of boxed groups: the panel is the page's left half, not an object + floating on it, and the same treatment covers all six generators because they + share this class. */ .media-controls { - background: var(--color-surface-raised); - border: 1px solid var(--color-border-subtle); - border-radius: var(--radius-lg); - padding: var(--spacing-lg); - box-shadow: var(--shadow-subtle); + background: transparent; + border: 0; + border-top: 1px solid var(--color-border-default); + border-radius: 0; + padding: 0; + box-shadow: none; position: sticky; top: var(--spacing-lg); } + +/* Each control is a row on the stack, separated by the same hairline the lanes + use, so a form and a list read as the same system. */ .media-controls .form-group { - margin-bottom: var(--spacing-md); + margin-bottom: 0; + padding: var(--spacing-sm) 0; + border-bottom: 1px solid var(--color-border-default); +} + +/* Uppercase micro-label, matching the eyebrow treatment used elsewhere: it + names the field without competing with the value. */ +.media-controls .form-label { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +/* The submit row closes the stack, so it carries no divider of its own. */ +.media-controls .form-group:last-of-type, +.media-controls > form > .btn-full:last-child { + border-bottom: 0; } .media-controls .form-grid-2col, .media-controls .form-grid-3col { @@ -8763,7 +8827,7 @@ button.collapsible-header:focus-visible { outline-offset: 2px; } -/* Manage summary marker — same .stat-grid layout. Top margin separates the +/* Manage summary marker — same .stat-cards layout. Top margin separates the cards from the System Resources card above (otherwise they sit too close to the RAM bar) and bottom margin tightens the gap to the tabs below. */ .manage-summary { @@ -8900,7 +8964,16 @@ button.collapsible-header:focus-visible { .console-rail { position: static; flex-basis: auto; width: 100%; } .console-rail-toggle { display: grid; } .console-rail-groups { display: none; } - .console-rail--open .console-rail-groups { display: flex; } + /* Thirteen destinations stacked in one column is taller than a phone, so + opening the menu used to push the page's own heading past the fold: the + menu replaced the page rather than annotating it. Bound it and let it + scroll, so the content behind stays reachable. */ + .console-rail--open .console-rail-groups { + display: flex; + max-height: 55vh; + overflow-y: auto; + overscroll-behavior: contain; + } } @media (prefers-reduced-motion: reduce) { .console-rail.console-rail--enter { animation: none; } @@ -12028,7 +12101,8 @@ button.collapsible-header:focus-visible { left: 0; right: 0; bottom: var(--discover-limit, 0); - border-top: 1px dashed var(--color-error-border); + /* Same tone as the bars that cross it: one colour, one meaning. */ + border-top: 1px dashed var(--color-warning-border); pointer-events: none; } @@ -12075,7 +12149,10 @@ button.collapsible-header:focus-visible { box-shadow: 0 0 0 2px var(--color-bg-secondary); } -.discover__chart-bar--over { background: var(--color-error); } +/* Amber, not error red. An over-limit build still installs — #11288 keeps a + test on exactly that — so red would overstate it. Amber is the constraint + tone used everywhere else here: know what you are doing, not you may not. */ +.discover__chart-bar--over { background: var(--color-warning); } .discover__chart-col:hover .discover__chart-bar { filter: brightness(1.1); } @@ -12217,6 +12294,13 @@ button.collapsible-header:focus-visible { pushes the page taller instead of scrolling inside it. */ min-height: 0; padding-bottom: var(--spacing-lg); + /* The shell above is overflow:hidden so the document cannot grow, which left + anything taller than the viewport simply unreachable — Host has a + resources card, four stat cards and a tab bar above its split, and at any + window height the bottom of the pane fell off with nothing to scroll. + The page scrolls inside the pinned shell; the rail and pane keep their own + inner scrollers for long lists and long details. */ + overflow-y: auto; } /* The header, fused. A slim row that reads as the top of the view rather than @@ -12266,6 +12350,10 @@ button.collapsible-header:focus-visible { } .page--app .split-view__pane { + /* The pane keeps its own scroller. Letting it grow instead would push the + document taller and stretch the rail to match, which is the regression + e2e/discover-height.spec.js exists to catch. Reaching tall page chrome is + the page's job (overflow-y above), not the pane's. */ height: 100%; overflow-y: auto; } @@ -12489,3 +12577,365 @@ button.collapsible-header:focus-visible { @media (prefers-reduced-motion: reduce) { .entity-rail__meta--pending { animation: none; opacity: 0.75; } } + + +/* ─── Lanes ─────────────────────────────────────────────────────────────── + The list idiom carried over from localai.io: full-bleed rows separated by a + single hairline, insetting on hover, with no card and no shadow. Used + wherever records are flat, uniform and read in sequence. Where an entity + outgrows a row and candidates get compared before acting, use SplitView + instead — that is the other half of the rule and they should not be mixed. + + Column templates belong to the caller: `.lanes-- .lane { grid-template-columns: ... }`. */ +.lanes { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--color-border-default); +} + +.lane { + display: grid; + align-items: center; + gap: var(--spacing-md); + width: 100%; + padding: var(--spacing-sm) var(--spacing-xs); + border: 0; + border-bottom: 1px solid var(--color-border-default); + background: transparent; + color: inherit; + text-align: left; + font: inherit; + text-decoration: none; + transition: background var(--duration-fast) var(--ease-default), + padding var(--duration-fast) var(--ease-default); +} + +a.lane, button.lane { cursor: pointer; } + +a.lane:hover, +button.lane:hover { + background: var(--color-bg-hover); + padding-inline: var(--spacing-sm); +} + +.lane:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; } + +/* A 33px row is fine for a mouse and too small for a thumb. Matches the rule + EntityRail already applies, so the two list idioms feel the same on touch. */ +@media (pointer: coarse) { + .lane { padding-top: 12px; padding-bottom: 12px; } +} + +/* The hover inset is a position change, so it is motion. Hold the background + feedback and drop the movement. */ +@media (prefers-reduced-motion: reduce) { + .lane { transition: background var(--duration-fast) var(--ease-default); } + a.lane:hover, button.lane:hover { padding-inline: var(--spacing-xs); } +} + +/* Uppercase micro-label. Blue where it names a route, mint where it reports + live state — never both on one screen. */ +.lane__tag { + color: var(--color-primary); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.lane__main { min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.lane__name { font-weight: var(--font-weight-medium); color: var(--color-text-primary); } + +/* Identifiers are monospace, prose is not. A model id, a backend name or a + filename is a thing you might type or paste, and setting it in the UI face + makes it read as a label instead. */ +.lane__name--id { font-family: var(--font-mono); font-size: 0.875rem; font-weight: 500; } +.lane__desc { color: var(--color-text-muted); font-size: 0.8125rem; } + +/* Identifiers and quantities are monospace; prose is not. */ +.lane__num { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-text-tertiary); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; +} + +.lane__go { color: var(--color-text-tertiary); } + +/* Section rhythm above a set of lanes. */ +.lane-head { + display: flex; + align-items: baseline; + gap: var(--spacing-sm); + margin: var(--spacing-2xl) 0 var(--spacing-sm); +} + +/* No :first-child reset. Every
makes its lane-head a first child, so + the reset silently killed the gap between every block — "Sections" sat flush + against the row above it. The header supplies its own bottom margin, so a + uniform top margin is right everywhere. */ +.lane-head h2 { margin: 0; font-size: 0.9375rem; font-weight: 700; letter-spacing: -0.01em; } + +.lane-head__meta { + margin-left: auto; + color: var(--color-text-tertiary); + font-family: var(--font-mono); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + +/* Standard padding for pages that are not inside a console shell, which + supplies its own. Without this a top-level page sits flush against the + sidebar and its first character is clipped. */ +.page-pad { + padding: var(--spacing-xl); + max-width: var(--page-max-medium); + width: 100%; +} + + +/* Home: resident models and the header figures. */ +.lanes--resident .lane { grid-template-columns: minmax(0, 1fr) auto auto; } + +.home-loaded-stop { + border: 0; + background: transparent; + color: var(--color-text-tertiary); + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +.home-loaded-stop:hover { color: var(--color-error); background: var(--color-error-light); } + +.home-stat { display: flex; align-items: baseline; gap: var(--spacing-xs); } + +.home-stat__value { + font-family: var(--font-mono); + font-size: 1.125rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--color-text-primary); + line-height: 1; +} + +.home-stat__value--ok { color: var(--color-success); } + +.home-stat__label { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +/* Headline figures on the Operate overview, and the sparklines under them. + Hairline-gridded cells rather than cards: the same 1px grid the split-view + StatGrid uses, so the two read as one system. */ +.operate-headline { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 1px; + margin: 0 0 var(--spacing-xl); + background: var(--color-border-default); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + overflow: hidden; +} + +.operate-headline__cell { + background: var(--color-bg-primary); + padding: var(--spacing-sm) var(--spacing-md); + display: grid; + gap: 2px; +} + +.operate-headline__cell dt { + color: var(--color-text-muted); + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.operate-headline__value { + margin: 0; + font-family: var(--font-mono); + font-size: 1.5rem; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.operate-headline__value--primary { color: var(--color-text-primary); } +.operate-headline__value--success { color: var(--color-success); } +.operate-headline__value--warning { color: var(--color-warning); } +.operate-headline__value--muted { color: var(--color-text-tertiary); } + +.sparkline { display: block; width: 100%; height: 28px; margin-top: 2px; } +.sparkline polyline { stroke: currentColor; } +.sparkline circle { fill: currentColor; } +.sparkline--primary { color: var(--color-primary); } +.sparkline--success { color: var(--color-success); } +.sparkline--warning { color: var(--color-warning); } +.sparkline--muted { color: var(--color-text-disabled); } + +/* A signal changing as a poll lands should read as an update, not a jump cut. + Opacity only, so it stays on the compositor and cannot reflow the rail. */ +.nav-signal { transition: opacity var(--duration-normal) var(--ease-default); } + +/* The attention block is the one thing on the page that should announce + itself. A short, single reveal on the row's left edge, not on the text. */ +@keyframes attentionEdge { + from { border-left-color: transparent; } + to { border-left-color: var(--color-warning); } +} + +.lanes--attention .lane { + animation: attentionEdge var(--duration-reveal) var(--ease-reveal) both; +} + +@media (prefers-reduced-motion: reduce) { + .nav-signal { transition: none; } + .lanes--attention .lane { animation: none; } +} + +/* The request the form just built. Sunken and monospace: it is a record of what + was sent, not another control. */ +.request-panel { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-sunken); + overflow: hidden; + margin-bottom: var(--spacing-md); +} + +.request-panel__head { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: 4px var(--spacing-sm); + border-bottom: 1px solid var(--color-border-default); +} + +.request-panel__title { + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.request-panel__copy { + margin-left: auto; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + font-size: 0.6875rem; + padding: 2px 8px; + cursor: pointer; +} + +.request-panel__copy:hover { color: var(--color-primary); border-color: var(--color-primary-border); } +.request-panel__copy:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 1px; } + +.request-panel__code { + margin: 0; + padding: var(--spacing-sm); + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.6875rem; + line-height: 1.6; + color: var(--color-text-secondary); +} + +.request-panel__method { color: var(--color-text-tertiary); } +.request-panel__endpoint { color: var(--color-primary); font-weight: 500; } + +/* A row of page-header controls. Replaces the inline flex that several pages + had each written out longhand. */ +.header-actions { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +/* The file input is triggered by its label, which is styled as a button; the + input itself must not render or the control shows twice. */ +.agents-import-input input[type="file"] { display: none; } + +/* ─── Operate console: rail signals and the overview ────────────────────── + The signal is ambient state beside a rail label, not a badge: no pill, no + fill, just a dimmer tabular figure a scanning eye can ignore. The + alarm-shaped treatment belongs to .nav-badge on the sidebar, which stays + visible when this rail is collapsed or absent. */ +.nav-signal { + margin-left: auto; + flex: none; + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--color-text-tertiary); + font-variant-numeric: tabular-nums; +} + +.nav-item.active .nav-signal { color: var(--color-primary); } + +/* One line, deliberately quiet. Making "nothing is wrong" as loud as a problem + is how a status page teaches people to stop reading it. */ +.operate-clear { + margin: 0; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.lanes--attention .lane { grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto; } +.lanes--sections .lane { grid-template-columns: minmax(0, 10rem) minmax(0, 1fr) auto; } + +/* Amber on the row itself rather than a status chip: these are the only things + on the page asking for a decision, so they can afford the one accent. */ +.lanes--attention .lane { border-left: 3px solid var(--color-warning); } + +/* ─── Studio: capability dots ───────────────────────────────────────────── + Filled means a model on this machine serves that modality. aria-hidden, + because the overview states the same thing in words and a dot that changes + as models load would otherwise interrupt a reader mid-strip. */ +.studio-tab__dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex: none; + margin-left: var(--spacing-xs); +} + +.studio-tab__dot--on { background: var(--color-success); } +.studio-tab__dot--off { + background: transparent; + box-shadow: inset 0 0 0 1px var(--color-border-strong); +} + +/* Room for the description to sit on one line at a comfortable width instead + of wrapping into a narrow column beside empty space. */ +.lanes--modality .lane { grid-template-columns: 6rem minmax(0, 1fr) minmax(0, 14rem) 5rem auto; } +.lanes--takes .lane { grid-template-columns: minmax(0, 1fr) auto; } + +.studio-modality--empty { cursor: default; } +.studio-modality__install { color: var(--color-primary); font-size: 0.8125rem; font-weight: 700; white-space: nowrap; } +.studio-modality__state { color: var(--color-success); font-size: 0.8125rem; font-weight: 600; } + +.studio-running__meter { height: 4px; border-radius: 2px; background: var(--color-bg-tertiary); overflow: hidden; width: 8rem; } +.studio-running__meter i { display: block; height: 100%; background: var(--color-primary); } + +.studio-overview__heading { display: flex; align-items: baseline; gap: var(--spacing-sm); } +.studio-overview__count { + margin-left: auto; + color: var(--color-text-tertiary); + font-family: var(--font-mono); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} diff --git a/core/http/react-ui/src/components/ManageSummary.jsx b/core/http/react-ui/src/components/ManageSummary.jsx index d02e4cae9..02ed10e2f 100644 --- a/core/http/react-ui/src/components/ManageSummary.jsx +++ b/core/http/react-ui/src/components/ManageSummary.jsx @@ -15,7 +15,7 @@ export default function ManageSummary({ const click = (tab, filter) => onCardClick && onCardClick(tab, filter) return ( -
+
{ + const ok = await copyToClipboard(curl) + if (!ok) return + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+ {t('request.heading')} + +
+
+        
+          {method}{' '}
+          {endpoint}
+          {'\n'}
+          {json}
+        
+      
+
+ ) +} diff --git a/core/http/react-ui/src/components/Sparkline.jsx b/core/http/react-ui/src/components/Sparkline.jsx new file mode 100644 index 000000000..372cd8b5f --- /dev/null +++ b/core/http/react-ui/src/components/Sparkline.jsx @@ -0,0 +1,40 @@ +// A bare trend line: stroke, no fill, no axes, no gridlines. +// +// It sits under a figure that already states the value, so its only job is to +// say what shape got us here. An emphasised endpoint marks "now", because the +// most recent point is the one being read. +// +// aria-hidden: the number above it is the accessible content, and a +// twelve-point series read aloud is noise. +export default function Sparkline({ points, tone = 'primary', width = 120, height = 28 }) { + if (!Array.isArray(points) || points.length < 2) return null + + const max = Math.max(...points) + // A flat run of zeroes would otherwise divide by zero and draw nothing; + // pinning it to the baseline is the honest picture of "no traffic". + const scale = max > 0 ? max : 1 + const step = width / (points.length - 1) + const y = v => height - 2 - (v / scale) * (height - 4) + + const path = points.map((v, i) => `${(i * step).toFixed(1)},${y(v).toFixed(1)}`).join(' ') + const lastX = width + const lastY = y(points[points.length - 1]) + + return ( + + ) +} diff --git a/core/http/react-ui/src/components/VoiceVisualizer.jsx b/core/http/react-ui/src/components/VoiceVisualizer.jsx index 9d814b5e5..13b4d15db 100644 --- a/core/http/react-ui/src/components/VoiceVisualizer.jsx +++ b/core/http/react-ui/src/components/VoiceVisualizer.jsx @@ -65,7 +65,7 @@ export default function VoiceVisualizer({ audioRef, micStreamRef, status, active let data = null if (an) { data = new Uint8Array(an.frequencyBinCount); an.getByteFrequencyData(data) } - const color = getComputedStyle(canvas).getPropertyValue('--viz-color').trim() || '#88c0d0' + const color = getComputedStyle(canvas).getPropertyValue('--viz-color').trim() || '#4f8cff' ctx.fillStyle = color const slot = w / BARS const bw = slot * 0.5 diff --git a/core/http/react-ui/src/components/audio/WaveformPlayer.jsx b/core/http/react-ui/src/components/audio/WaveformPlayer.jsx index 805a9a447..00c2e56e8 100644 --- a/core/http/react-ui/src/components/audio/WaveformPlayer.jsx +++ b/core/http/react-ui/src/components/audio/WaveformPlayer.jsx @@ -44,7 +44,7 @@ export default function WaveformPlayer({ const accent = getComputedStyle(canvas).getPropertyValue('--audio-wave').trim() || getComputedStyle(canvas).getPropertyValue('--color-primary').trim() || - '#88c0d0' + '#4f8cff' ctx.fillStyle = dimmed ? withAlpha(accent, 0.32) : accent const mid = cssH / 2 const barW = Math.max(1, cssW / peaks.length) diff --git a/core/http/react-ui/src/components/console/ConsoleLayout.jsx b/core/http/react-ui/src/components/console/ConsoleLayout.jsx index 8ad3f8a95..fa19c858c 100644 --- a/core/http/react-ui/src/components/console/ConsoleLayout.jsx +++ b/core/http/react-ui/src/components/console/ConsoleLayout.jsx @@ -6,6 +6,7 @@ import { apiUrl } from '../../utils/basePath' import { preloadRoute } from '../../router' import RouteFallback from '../RouteFallback' import { isConsoleItemVisible } from './consoleConfig' +import { OperateSummaryProvider, useOperateSummary } from '../../contexts/OperateSummaryContext' // The App wraps the outlet in key={pathname}, so this layout remounts on every // sub-navigation. Tracking the last-entered console id across mounts lets us @@ -24,6 +25,11 @@ let featuresCache = {} // route in router.jsx — wrapped pages keep their existing flat URLs. function RailItem({ item, label }) { + // Null outside Operate, where no provider is mounted — the rail then renders + // exactly as it did before signals existed. + const summary = useOperateSummary() + const signal = item.signal ? summary?.signals?.[item.signal] : null + if (item.external) { return ( @@ -42,11 +48,15 @@ function RailItem({ item, label }) { > {label} + {/* Ambient, and hidden from assistive tech: it changes under the reader + and is never the only place a fact appears. The overview states the + same things in prose, where they can be read deliberately. */} + {signal != null && } ) } -export default function ConsoleLayout({ config }) { +function ConsoleLayoutInner({ config }) { const { t } = useTranslation('nav') const { isAdmin, authEnabled, hasFeature } = useAuth() const [features, setFeatures] = useState(featuresCache) @@ -115,3 +125,15 @@ export default function ConsoleLayout({ config }) {
) } + +// The summary provider wraps the Operate console and nothing else. That is the +// whole of "poll only while the user is in Operate": elsewhere the provider is +// not mounted, so no timer exists to gate. Build gets the plain layout. +export default function ConsoleLayout({ config }) { + if (config.id !== 'operate') return + return ( + + + + ) +} diff --git a/core/http/react-ui/src/components/console/consoleConfig.js b/core/http/react-ui/src/components/console/consoleConfig.js index 482d4cc30..e03f3c80b 100644 --- a/core/http/react-ui/src/components/console/consoleConfig.js +++ b/core/http/react-ui/src/components/console/consoleConfig.js @@ -44,28 +44,38 @@ export const buildConsole = { ], } +// Four groups, not six. Inference and Activity were both "the runtime right +// now"; Access and System were both administration. Six headings over thirteen +// items is a list with extra steps. Nothing is removed and no gate changes — +// the heading an item sits under is the only thing that moves. +// +// Overview leads the first group so firstVisiblePath() returns it without +// needing to know it exists. Before it, opening Operate landed on Backends +// because Backends happened to be written first. +// +// `signal` names a value the rail renders beside the label (see +// OperateSummaryContext). It is orientation while inside Operate, NOT an +// alarm: the rail exists only on Operate routes and can be collapsed, which is +// precisely why the operations badge stays on the sidebar entry. Nothing +// urgent may depend on a rail signal alone. export const operateConsole = { id: 'operate', titleKey: 'sections.operate', icon: 'fas fa-sliders', groups: [ { - titleKey: 'operate.inference', + titleKey: 'operate.runtime', items: [ - { path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true }, + { path: '/app/operate', icon: 'fas fa-gauge-high', labelKey: 'items.overview', adminOnly: true, signal: 'attention' }, + { path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true, signal: 'backends' }, { path: '/app/voice-library', icon: 'fas fa-wave-square', labelKey: 'items.voiceLibrary', adminOnly: true }, - ], - }, - { - titleKey: 'operate.activity', - items: [ - { path: '/app/activity', icon: 'fas fa-download', labelKey: 'items.activity', adminOnly: true, badge: 'operations' }, + { path: '/app/activity', icon: 'fas fa-download', labelKey: 'items.activity', adminOnly: true, badge: 'operations', signal: 'activity' }, ], }, { titleKey: 'operate.cluster', items: [ - { path: '/app/nodes', icon: 'fas fa-network-wired', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed' }, + { path: '/app/nodes', icon: 'fas fa-network-wired', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed', signal: 'nodes' }, { path: '/app/scheduling', icon: 'fas fa-calendar-alt', labelKey: 'items.scheduling', adminOnly: true, feature: 'distributed' }, { path: '/app/p2p', icon: 'fas fa-circle-nodes', labelKey: 'items.swarm', adminOnly: true }, ], @@ -73,21 +83,16 @@ export const operateConsole = { { titleKey: 'operate.observability', items: [ - { path: '/app/usage', icon: 'fas fa-chart-bar', labelKey: 'items.usage', adminOnly: true }, - { path: '/app/traces', icon: 'fas fa-chart-line', labelKey: 'items.traces', adminOnly: true }, + { path: '/app/usage', icon: 'fas fa-chart-bar', labelKey: 'items.usage', adminOnly: true, signal: 'usage' }, + { path: '/app/traces', icon: 'fas fa-chart-line', labelKey: 'items.traces', adminOnly: true, signal: 'traces' }, ], }, { - titleKey: 'operate.access', + titleKey: 'operate.administration', items: [ { path: '/app/users', icon: 'fas fa-users', labelKey: 'items.users', adminOnly: true, authOnly: true }, { path: '/app/middleware', icon: 'fas fa-shield-halved', labelKey: 'items.middleware', adminOnly: true }, - ], - }, - { - titleKey: 'operate.system', - items: [ - { path: '/app/manage', icon: 'fas fa-desktop', labelKey: 'items.host', adminOnly: true }, + { path: '/app/manage', icon: 'fas fa-desktop', labelKey: 'items.host', adminOnly: true, signal: 'host' }, { path: '/app/settings', icon: 'fas fa-cog', labelKey: 'items.settings', adminOnly: true }, { href: '/swagger/index.html', icon: 'fas fa-code', labelKey: 'items.api', external: true, adminOnly: true }, ], diff --git a/core/http/react-ui/src/contexts/OperateSummaryContext.jsx b/core/http/react-ui/src/contexts/OperateSummaryContext.jsx new file mode 100644 index 000000000..5831f5869 --- /dev/null +++ b/core/http/react-ui/src/contexts/OperateSummaryContext.jsx @@ -0,0 +1,154 @@ +import { createContext, useContext, useState, useCallback, useMemo } from 'react' +import { backendsApi, nodesApi, resourcesApi, tracesApi } from '../utils/api' +import { usePolling } from '../hooks/usePolling' +import { useOperations } from '../hooks/useOperations' +import { useDistributedMode } from '../hooks/useDistributedMode' + +// The state of the installation, assembled once for everything in the Operate +// console: the rail's per-item signals and the overview's attention list. +// +// One poller, following OperationsContext — which exists because every +// consumer running its own setInterval against the same endpoint was the +// defect it was created to fix. Four endpoints doing that would be worse. +// +// The provider is mounted by ConsoleLayout for the Operate console only, so +// "poll only while the user is in Operate" needs no route check: away from +// Operate the provider is not mounted and nothing runs. +// +// Operations are NOT fetched here. OperationsContext already polls them for +// the sidebar badge and the operations strip, so this reads that instead of +// adding a second poll of /api/operations. +const OperateSummaryContext = createContext(null) + +// Slower than the operations poll (1s) on purpose. Nothing here changes on a +// human timescale — a backend does not go stale mid-glance — and the values +// are ambient rather than awaited. +const POLL_INTERVAL_MS = 15_000 + +// A failed summary is not an event. Nobody asked for this data; it decorates a +// rail and fills a panel. Each source degrades to "no signal" on its own so one +// dead endpoint cannot blank the other three. +async function settle(promise, fallback) { + try { + return await promise + } catch { + return fallback + } +} + +export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_MS }) { + const [upgrades, setUpgrades] = useState({}) + const [nodes, setNodes] = useState([]) + const [resources, setResources] = useState(null) + const [traces, setTraces] = useState(null) + const { operations } = useOperations() + // The cluster API answers 503 when distributed mode is off, so asking for it + // on a single-node install is a guaranteed miss on every tick. The rail gates + // the Nodes entry the same way. + const { enabled: distributed } = useDistributedMode() + + const fetchSummary = useCallback(async () => { + const [u, n, r, tr] = await Promise.all([ + // GET /api/backends/upgrades returns the upgrade checker's cached view. + // Never POST /upgrades/check on a timer — that forces a real registry + // check. + settle(backendsApi.checkUpgrades(), {}), + distributed ? settle(nodesApi.list(), []) : Promise.resolve([]), + settle(resourcesApi.get(), null), + settle(tracesApi.summary(), null), + ]) + setUpgrades(u && typeof u === 'object' ? u : {}) + setNodes(Array.isArray(n) ? n : (n?.nodes || [])) + setResources(r) + setTraces(tr) + }, [distributed]) + + usePolling(fetchSummary, pollInterval) + + const value = useMemo(() => { + const upgradeList = Object.values(upgrades || {}) + const failedOps = operations.filter(op => op.error) + const unhealthyNodes = nodes.filter(n => nodeUnhealthy(n)) + + // Only things a person would act on. An operation in flight is not a + // problem, so a running install belongs in the rail's activity count and + // not in here. + const attention = [ + ...upgradeList.map(u => ({ + id: `upgrade:${u.backend_name}`, + kind: 'backend-update', + name: u.backend_name, + from: u.installed_version, + to: u.available_version, + })), + ...failedOps.map(op => ({ + id: `op:${op.id || op.uid || op.name}`, + kind: 'operation-failed', + name: op.name || op.id, + detail: op.error, + })), + ...unhealthyNodes.map(n => ({ + id: `node:${n.id || n.name}`, + kind: 'node-unhealthy', + name: n.id || n.name, + detail: n.status, + })), + ] + + return { + upgrades, + nodes, + resources, + operations, + traces, + attention, + signals: { + attention: attention.length || null, + backends: upgradeList.length || null, + activity: operations.length || null, + nodes: nodes.length ? `${nodes.length - unhealthyNodes.length}/${nodes.length}` : null, + host: memoryPercent(resources), + traces: traces?.errors || null, + usage: traces?.total ? compact(traces.total) : null, + }, + } + }, [upgrades, nodes, resources, operations, traces]) + + return ( + + {children} + + ) +} + +// Node payloads have carried `healthy`, `status` and neither at different +// points. Treat an explicit negative as unhealthy and anything unrecognised as +// fine, so a shape change makes the panel quiet rather than crying wolf. +function nodeUnhealthy(node) { + if (node?.healthy === false) return true + if (typeof node?.status === 'string') { + return ['unhealthy', 'down', 'offline', 'error'].includes(node.status.toLowerCase()) + } + return false +} + +// 18402 -> 18.4k. A rail signal has room for a shape, not a full figure. +function compact(n) { + if (n < 1000) return String(n) + if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k` + return `${(n / 1_000_000).toFixed(1)}M` +} + +function memoryPercent(resources) { + const ram = resources?.ram || resources?.aggregate?.ram + const total = ram?.total ?? ram?.total_bytes + const used = ram?.used ?? ram?.used_bytes + if (!(total > 0) || !(used >= 0)) return null + return `${Math.round((used / total) * 100)}%` +} + +// Returns null outside the Operate console, where the provider is not mounted. +// Callers render nothing rather than guessing at a value. +export function useOperateSummary() { + return useContext(OperateSummaryContext) +} diff --git a/core/http/react-ui/src/contexts/ThemeContext.jsx b/core/http/react-ui/src/contexts/ThemeContext.jsx index 24b911900..9fa804d6b 100644 --- a/core/http/react-ui/src/contexts/ThemeContext.jsx +++ b/core/http/react-ui/src/contexts/ThemeContext.jsx @@ -2,10 +2,13 @@ import { createContext, useContext, useState, useEffect } from 'react' const ThemeContext = createContext() +// Dark is the identity, not a preference: localai.io ships one theme and it is +// this one, so an install should look like LocalAI before anyone has chosen +// anything. The OS setting no longer picks light on first load — the toggle +// does, and once used it is remembered and wins forever after. function getInitialTheme() { const stored = localStorage.getItem('localai-theme') if (stored) return stored - if (window.matchMedia?.('(prefers-color-scheme: light)').matches) return 'light' return 'dark' } diff --git a/core/http/react-ui/src/hooks/useMediaHistory.js b/core/http/react-ui/src/hooks/useMediaHistory.js index 8e283045b..6889cd640 100644 --- a/core/http/react-ui/src/hooks/useMediaHistory.js +++ b/core/http/react-ui/src/hooks/useMediaHistory.js @@ -12,6 +12,21 @@ const STORAGE_KEYS = { const SAVE_DEBOUNCE_MS = 500 const MAX_ENTRIES = 100 +// Read every store at once, without mounting a hook per media type. +// +// The Studio overview reads across all of them and writes to none, while +// useMediaHistory() carries save timers and selection state it would have no +// use for. Note there is no 'threed' store here: 3D history lives in IndexedDB +// rather than localStorage because its entries carry multi-megabyte GLB blobs, +// so callers wanting 3D read use3DHistory alongside this. +export function readAllMediaHistory() { + const all = {} + for (const [mediaType, key] of Object.entries(STORAGE_KEYS)) { + all[mediaType] = loadEntries(key) + } + return all +} + function loadEntries(key) { try { const stored = localStorage.getItem(key) diff --git a/core/http/react-ui/src/pages/Agents.jsx b/core/http/react-ui/src/pages/Agents.jsx index 218e0ee6e..cd9024f59 100644 --- a/core/http/react-ui/src/pages/Agents.jsx +++ b/core/http/react-ui/src/pages/Agents.jsx @@ -186,18 +186,20 @@ export default function Agents() { title={t('title')} supporting={t('subtitle')} actions={ -
+
{agentHubURL && ( - - {t('actions.agentHub')} + + {t('actions.agentHub')} )} -
} diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index bc0937281..28e2c36d5 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -1214,9 +1214,15 @@ export default function Chat() {
+ {/* Both roles are labelled now that neither is a bubble. + A transcript needs to say who is speaking; a bubble said + it by shape and side. */} {msg.role === 'assistant' && activeChat.model && ( {activeChat.model} )} + {msg.role === 'user' && ( + {t('message.you')} + )} {editingMessageIndex === i ? (