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 ? (