From db0e95d8489b0b60b1621d5d1829c31250c496f5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:14:13 +0200 Subject: [PATCH 1/3] feat(gui): add Usage workspace wired to global view toggle Add a section rail (Overview/Models/Providers/Coverage) driven by the sidebar Classic/Workspace preference, with container-query stacking and no per-page toggle. --- gui/src/App.tsx | 2 +- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Usage.tsx | 336 +++++++++++++++++++++++------ gui/src/styles-usage-workspace.css | 178 +++++++++++++++ gui/src/styles.css | 1 + gui/tests/usage-workspace.test.ts | 38 ++++ 11 files changed, 492 insertions(+), 69 deletions(-) create mode 100644 gui/src/styles-usage-workspace.css create mode 100644 gui/tests/usage-workspace.test.ts diff --git a/gui/src/App.tsx b/gui/src/App.tsx index e205469cd..c1fd196b2 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -281,7 +281,7 @@ export default function App() { {page === "combos" && } {page === "subagents" && } {page === "logs" && } - {page === "usage" && } + {page === "usage" && } {page === "storage" && } {page === "codex-auth" && } {page === "api" && } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6c69b33ee..f0d6a6042 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -548,6 +548,7 @@ export const de = { "usage.card.coverage": "Abdeckung", "usage.card.activeDays": "Aktive Tage", "usage.section.heatmap": "Tägliche Aktivität", + "usage.section.overview": "Übersicht", "usage.section.models": "Modelle", "usage.section.providers": "Anbieter", "usage.section.coverage": "Abdeckungs-Aufschlüsselung", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ed890ecfd..af40c446b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -572,6 +572,7 @@ export const en = { "usage.card.coverage": "Coverage", "usage.card.activeDays": "Active days", "usage.section.heatmap": "Daily activity", + "usage.section.overview": "Overview", "usage.section.models": "Models", "usage.section.providers": "Providers", "usage.section.coverage": "Coverage breakdown", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 114cd7b22..3f1f2059b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -537,6 +537,7 @@ export const ja: Record = { "usage.card.coverage": "カバレッジ", "usage.card.activeDays": "アクティブ日数", "usage.section.heatmap": "日のアクティビティ", + "usage.section.overview": "概要", "usage.section.models": "モデル", "usage.section.providers": "プロバイダー", "usage.section.coverage": "カバレッジ内訳", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7d8754059..aeb04bff6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -565,6 +565,7 @@ export const ko: Record = { "usage.card.coverage": "커버리지", "usage.card.activeDays": "활동일", "usage.section.heatmap": "일별 활동", + "usage.section.overview": "개요", "usage.section.models": "모델", "usage.section.providers": "프로바이더", "usage.section.coverage": "커버리지 상세", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a74b75a9e..52950deb2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -572,6 +572,7 @@ export const ru: Record = { "usage.card.coverage": "Покрытие", "usage.card.activeDays": "Активные дни", "usage.section.heatmap": "Активность по дням", + "usage.section.overview": "Обзор", "usage.section.models": "Модели", "usage.section.providers": "Провайдеры", "usage.section.coverage": "Детализация покрытия", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 81a2c961e..5e03ceac5 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -565,6 +565,7 @@ export const zh: Record = { "usage.card.coverage": "覆盖率", "usage.card.activeDays": "活跃天数", "usage.section.heatmap": "每日活动", + "usage.section.overview": "概览", "usage.section.models": "模型", "usage.section.providers": "提供方", "usage.section.coverage": "覆盖率明细", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 2274ea269..b5c8f4797 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { formatTokens } from "../format-tokens"; import { EmptyState, Notice } from "../ui"; import { modelLabel } from "../model-display"; +import { readViewMode, type ViewMode } from "../view-mode"; type Range = "all" | "30d" | "7d"; type UsageSurface = "all" | "codex" | "claude"; @@ -449,93 +450,145 @@ function UsageModelsTable({ onModelQuery, locale, t, + workspace = false, }: { models: UsageModel[]; modelQuery: string; onModelQuery: (query: string) => void; locale: Locale; t: TFn; + workspace?: boolean; }) { const searchLabel = t("usage.search.models"); + const sectionLabel = t("usage.section.models"); + const titleId = "usage-models-title"; + const searchInput = ( + onModelQuery(event.target.value)} + /> + ); + const table = ( + + + + + {t("logs.col.model")} + {t("logs.col.provider")} + {t("usage.col.requests")} + {t("usage.col.measured")} + {t("usage.col.tokens")} + {t("usage.col.share")} + + + + {models.map(model => ( + + {modelLabel(model.model)} + {model.provider} + {model.requests} + {model.measuredRequests} + {formatTokens(model.totalTokens, locale)} + + + ))} + + + + ); + + if (workspace) { + return ( + + {searchInput} + {table} + + ); + } return ( - + - {t("usage.section.models")} - onModelQuery(event.target.value)} - /> - - - - - - {t("logs.col.model")} - {t("logs.col.provider")} - {t("usage.col.requests")} - {t("usage.col.measured")} - {t("usage.col.tokens")} - {t("usage.col.share")} - - - - {models.map(model => ( - - {modelLabel(model.model)} - {model.provider} - {model.requests} - {model.measuredRequests} - {formatTokens(model.totalTokens, locale)} - - - ))} - - + {sectionLabel} + {searchInput} + {table} ); } -function UsageProvidersTable({ providers, locale, t }: { providers: UsageProvider[]; locale: Locale; t: TFn }) { - return ( - - {t("usage.section.providers")} - - - - - {t("logs.col.provider")} - {t("usage.col.requests")} - {t("usage.col.measured")} - {t("usage.col.tokens")} - {t("usage.col.share")} +function UsageProvidersTable({ + providers, + locale, + t, + workspace = false, +}: { + providers: UsageProvider[]; + locale: Locale; + t: TFn; + workspace?: boolean; +}) { + const sectionLabel = t("usage.section.providers"); + const titleId = "usage-providers-title"; + const table = ( + + + + + {t("logs.col.provider")} + {t("usage.col.requests")} + {t("usage.col.measured")} + {t("usage.col.tokens")} + {t("usage.col.share")} + + + + {providers.map(provider => ( + + {provider.provider} + {provider.requests} + {provider.measuredRequests} + {formatTokens(provider.totalTokens, locale)} + - - - {providers.map(provider => ( - - {provider.provider} - {provider.requests} - {provider.measuredRequests} - {formatTokens(provider.totalTokens, locale)} - - - ))} - - - + ))} + + + + ); + + if (workspace) { + return ( + + {table} + + ); + } + + return ( + + {sectionLabel} + {table} ); } -function UsageCoveragePanel({ summary, t }: { summary: UsageSummaryTotals; t: TFn }) { - return ( - - {t("usage.section.coverage")} +function UsageCoveragePanel({ + summary, + t, + workspace = false, +}: { + summary: UsageSummaryTotals; + t: TFn; + workspace?: boolean; +}) { + const sectionLabel = t("usage.section.coverage"); + const titleId = "usage-coverage-title"; + const body = ( + <> {t("usage.coverage.measured")}{summary.measuredRequests} {t("usage.coverage.reported")}{summary.reportedRequests} @@ -544,11 +597,140 @@ function UsageCoveragePanel({ summary, t }: { summary: UsageSummaryTotals; t: TF {t("logs.tokens.unsupported")}{summary.unsupportedRequests} {t("usage.coverage.note")} + > + ); + + if (workspace) { + return ( + + {body} + + ); + } + + return ( + + {sectionLabel} + {body} ); } -export default function Usage({ apiBase }: { apiBase: string }) { +function UsageWorkspaceSection({ + title, + titleId, + children, +}: { + title: string; + titleId: string; + children: ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +/** + * Workspace layout for the Usage tab: a left rail lists the report sections + * (Overview, Models, Providers, Coverage) and the main pane renders the selected + * one. Section bodies reuse the same panel components as the classic stacked view. + */ +function UsageWorkspaceBody({ + data, + heatmap, + weekBars, + activeDays, + filteredModels, + modelQuery, + onModelQuery, + sortedProviders, + range, + selectedSection, + onSelectSection, + locale, + t, +}: { + data: UsageResponse; + heatmap: ReturnType; + weekBars: UsageDay[]; + activeDays: number; + filteredModels: UsageModel[]; + modelQuery: string; + onModelQuery: (query: string) => void; + sortedProviders: UsageProvider[]; + range: Range; + selectedSection: string; + onSelectSection: (id: string) => void; + locale: Locale; + t: TFn; +}) { + const sections = [ + { + id: "overview", + label: t("usage.section.overview"), + meta: `${data.summary.requests}`, + body: ( + <> + + + > + ), + }, + { + id: "models", + label: t("usage.section.models"), + meta: `${data.models.length}`, + body: , + }, + { + id: "providers", + label: t("usage.section.providers"), + meta: `${data.providers.length}`, + body: , + }, + { + id: "coverage", + label: t("usage.section.coverage"), + meta: formatPct(data.summary.coverageRatio), + body: , + }, + ]; + const selected = sections.find(s => s.id === selectedSection) ?? sections[0]; + + return ( + + + + + {selected.body} + + + + ); +} + +export default function Usage({ apiBase, viewMode }: { apiBase: string; viewMode?: ViewMode }) { const { t, locale } = useI18n(); const [range, setRange] = useState("30d"); const [surface, setSurface] = useState("all"); @@ -556,6 +738,8 @@ export default function Usage({ apiBase }: { apiBase: string }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [modelQuery, setModelQuery] = useState(""); + const [selectedSection, setSelectedSection] = useState("overview"); + const workspaceView = (viewMode ?? readViewMode()) === "workspace"; const fetchUsage = useCallback(async (nextRange: Range, nextSurface: UsageSurface, signal: AbortSignal) => { setLoading(true); @@ -631,6 +815,22 @@ export default function Usage({ apiBase }: { apiBase: string }) { ) : data?.summary.requests === 0 ? ( + ) : data && workspaceView ? ( + ) : data ? ( <> diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css new file mode 100644 index 000000000..b4d9cf77d --- /dev/null +++ b/gui/src/styles-usage-workspace.css @@ -0,0 +1,178 @@ +/* ============================================================================ + usage-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) + Namespace: usage-workspace- | widgets: usw- + Uses only design tokens from styles.css. No gradients. + ============================================================================ */ + +.main-inner:has(.usage-workspace-shell) { + max-width: 1200px; +} + +.usage-workspace-shell { + width: 100%; + min-width: 0; + container-type: inline-size; + container-name: usage-workspace; +} + +.usage-workspace-root { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + gap: var(--space-4); + width: 100%; + max-width: 100%; + min-height: 480px; +} + +/* ── Rail ─────────────────────────────────────────────── */ + +.usage-workspace-rail { + display: flex; + flex-direction: column; + gap: var(--space-3); + border-right: 1px solid var(--border); + padding-right: var(--space-3); + min-width: 0; +} + +.usage-workspace-rail-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.usage-workspace-rail-title { + font-size: var(--text-body); + font-weight: var(--weight-semibold); + color: var(--text); +} + +.usage-workspace-rail-list { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; +} + +.usage-workspace-rail-row { + display: flex; + flex-direction: column; + gap: var(--space-0-5); + max-width: 100%; + min-width: 0; + min-height: 46px; + appearance: none; + background: none; + border: none; + border-radius: var(--radius-sm); + padding: var(--space-1-5) var(--space-2); + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + overflow: hidden; +} + +.usage-workspace-rail-row:hover { + background: var(--raised); +} + +.usage-workspace-rail-row--selected { + background: var(--raised); + box-shadow: inset 0 0 0 1px var(--border); +} + +.usage-workspace-rail-name { + font-size: var(--text-body); + font-weight: var(--weight-medium); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.usage-workspace-rail-meta { + font-size: var(--text-caption); + color: var(--faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + +/* ── Main pane ────────────────────────────────────────── */ + +.usage-workspace-main { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 0; +} + +.usw-body { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 0; +} + +.usw-section { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; +} + +.usw-section .h-section { + margin: 0 0 var(--space-3); + font-size: var(--text-title); + font-weight: var(--weight-semibold); + line-height: 1.2; + color: var(--text); +} + +.usw-section-toolbar { + margin: 0 0 var(--space-5); + max-width: 220px; +} + +.usw-section .tbl-wrap { + min-width: 0; +} + +.usw-section .usage-cards { + margin-top: 0; +} + +.usw-section .usage-scroll { + max-height: 520px; +} + +/* ── Responsive ───────────────────────────────────────── */ + +@container usage-workspace (max-width: 720px) { + .usage-workspace-root { + grid-template-columns: 1fr; + } + + .usage-workspace-rail { + border-right: none; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + } +} + +@media (max-width: 768px) { + .usage-workspace-root { + grid-template-columns: 1fr; + } + + .usage-workspace-rail { + border-right: none; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index c57f9ce13..dc4b7bff6 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -16,6 +16,7 @@ @import "./styles-models-workspace.css"; @import "./styles-dashboard-workspace.css"; @import "./styles-subagents-workspace.css"; +@import "./styles-usage-workspace.css"; :root { /* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */ diff --git a/gui/tests/usage-workspace.test.ts b/gui/tests/usage-workspace.test.ts new file mode 100644 index 000000000..a2e2506f1 --- /dev/null +++ b/gui/tests/usage-workspace.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; + +test("Usage uses global viewMode (no per-page toggle) and workspace shell", async () => { + const page = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); + const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); + const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + + expect(page).toContain("viewMode"); + expect(page).toContain("readViewMode"); + expect(page).toContain("UsageWorkspaceBody"); + expect(page).toContain('workspaceView = (viewMode ?? readViewMode()) === "workspace"'); + expect(page).not.toContain("ocx-usage-view"); + expect(page).not.toContain("pws.workspaceToggle"); + expect(page).not.toContain("pws.classicToggle"); + + expect(app).toContain(""); + expect(css).toContain('@import "./styles-usage-workspace.css"'); +}); + +test("Usage workspace uses section landmark and section rail", async () => { + const src = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); + expect(src).toContain("usage-workspace-shell"); + expect(src).toContain("usage-workspace-rail"); + expect(src).toContain('t("usage.section.overview")'); + expect(src).toContain('t("usage.section.models")'); + expect(src).toContain('t("usage.section.providers")'); + expect(src).toContain('t("usage.section.coverage")'); + expect(src).toContain(' { + const css = await Bun.file(new URL("../src/styles-usage-workspace.css", import.meta.url)).text(); + expect(css).toContain("container-name: usage-workspace"); + expect(css).toContain("container-type: inline-size"); + expect(css).toContain("@container usage-workspace (max-width: 720px)"); + expect(css).toContain("@media (max-width: 768px)"); +}); From 545d9cd6087bf10d4b4194a4df099131c7f36a30 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:19:06 +0200 Subject: [PATCH 2/3] fix(gui): stop Logs auto-refresh from flashing Loading above the table Keep polling every 2s, but silent background fetches no longer toggle loading or insert a status row that shifts the table. --- gui/src/pages/Logs.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index d1ddd5099..3d91ae258 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -277,8 +277,8 @@ export default function Logs({ apiBase }: { apiBase: string }) { else if (e.key === "ArrowRight" || e.key === "End") { e.preventDefault(); selectTab("debug"); document.getElementById("logs-tab-debug")?.focus(); } }; - const fetchLogs = useCallback(async () => { - setLoading(true); + const fetchLogs = useCallback(async (opts?: { silent?: boolean }) => { + if (!opts?.silent) setLoading(true); setError(null); try { const res = await fetch(`${apiBase}/api/logs`); @@ -288,7 +288,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { const detail = cause instanceof Error ? cause.message : ""; setError(detail ? `${t("logs.loadError")} ${detail}` : t("logs.loadError")); } finally { - setLoading(false); + if (!opts?.silent) setLoading(false); } }, [apiBase, t]); @@ -296,7 +296,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { if (tab !== "logs") return; void fetchLogs(); if (!autoRefresh) return; - const interval = setInterval(() => void fetchLogs(), 2000); + const interval = setInterval(() => void fetchLogs({ silent: true }), 2000); return () => clearInterval(interval); }, [autoRefresh, fetchLogs, tab]); @@ -402,7 +402,6 @@ export default function Logs({ apiBase }: { apiBase: string }) { ) : ( <> - {loading && {t("common.loading")}} From df59b246678e1a0383e3f5f27e33b2733c95fec1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:13:01 +0200 Subject: [PATCH 3/3] fix(gui): stabilize Logs silent refresh and Usage workspace a11y Keep silent log polls from clearing errors or flashing empty/loading UI, restore Usage workspace headings and distinct landmark labels, mount the workspace shell while loading, and hide Codex Auth from the sidebar in workspace mode (Providers deep links stay). --- gui/src/App.tsx | 2 +- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Logs.tsx | 10 +- gui/src/pages/Usage.tsx | 53 ++- gui/src/styles-usage-workspace.css | 1 + gui/tests/logs-auto-refresh.test.tsx | 334 ++++++++++++++++++ gui/tests/usage-workspace.test.ts | 43 +++ .../workspace-sidebar-codex-auth.test.ts | 9 + 13 files changed, 441 insertions(+), 23 deletions(-) create mode 100644 gui/tests/logs-auto-refresh.test.tsx create mode 100644 gui/tests/workspace-sidebar-codex-auth.test.ts diff --git a/gui/src/App.tsx b/gui/src/App.tsx index c1fd196b2..657d67994 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -214,7 +214,7 @@ export default function App() { - {NAV.map(({ id, tkey, Icon }) => ( + {NAV.filter(({ id }) => !(viewMode === "workspace" && id === "codex-auth")).map(({ id, tkey, Icon }) => ( { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f0d6a6042..23b7e6355 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -552,6 +552,8 @@ export const de = { "usage.section.models": "Modelle", "usage.section.providers": "Anbieter", "usage.section.coverage": "Abdeckungs-Aufschlüsselung", + "usage.workspace.sections": "Nutzungsabschnitte", + "usage.workspace.report": "Nutzungsbericht", "usage.coverage.measured": "Gemessen", "usage.coverage.reported": "Anbieter gemeldet", "usage.coverage.estimated": "Geschätzt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index af40c446b..772cc84d3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -576,6 +576,8 @@ export const en = { "usage.section.models": "Models", "usage.section.providers": "Providers", "usage.section.coverage": "Coverage breakdown", + "usage.workspace.sections": "Usage sections", + "usage.workspace.report": "Usage report", "usage.coverage.measured": "Measured", "usage.coverage.reported": "Provider reported", "usage.coverage.estimated": "Estimated", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3f1f2059b..2adafa1de 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -541,6 +541,8 @@ export const ja: Record = { "usage.section.models": "モデル", "usage.section.providers": "プロバイダー", "usage.section.coverage": "カバレッジ内訳", + "usage.workspace.sections": "使用量セクション", + "usage.workspace.report": "使用量レポート", "usage.coverage.measured": "計測", "usage.coverage.reported": "プロバイダー報告", "usage.coverage.estimated": "推定", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index aeb04bff6..777ed16c9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -569,6 +569,8 @@ export const ko: Record = { "usage.section.models": "모델", "usage.section.providers": "프로바이더", "usage.section.coverage": "커버리지 상세", + "usage.workspace.sections": "사용량 섹션", + "usage.workspace.report": "사용량 보고서", "usage.coverage.measured": "측정됨", "usage.coverage.reported": "제공자 보고", "usage.coverage.estimated": "추정", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 52950deb2..e3efe039a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -576,6 +576,8 @@ export const ru: Record = { "usage.section.models": "Модели", "usage.section.providers": "Провайдеры", "usage.section.coverage": "Детализация покрытия", + "usage.workspace.sections": "Разделы использования", + "usage.workspace.report": "Отчёт об использовании", "usage.coverage.measured": "Измерено", "usage.coverage.reported": "Сообщено провайдером", "usage.coverage.estimated": "Оценено", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5e03ceac5..9b2e162d0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -569,6 +569,8 @@ export const zh: Record = { "usage.section.models": "模型", "usage.section.providers": "提供方", "usage.section.coverage": "覆盖率明细", + "usage.workspace.sections": "用量分区", + "usage.workspace.report": "用量报告", "usage.coverage.measured": "已计量", "usage.coverage.reported": "提供方上报", "usage.coverage.estimated": "估算", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 3d91ae258..2e0a2ea58 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -278,17 +278,21 @@ export default function Logs({ apiBase }: { apiBase: string }) { }; const fetchLogs = useCallback(async (opts?: { silent?: boolean }) => { - if (!opts?.silent) setLoading(true); - setError(null); + const silent = opts?.silent === true; + // Silent polls must not clear an existing error or toggle loading — otherwise + // failures flicker between the error banner, empty state, and stale table. + if (!silent) setLoading(true); try { const res = await fetch(`${apiBase}/api/logs`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); setLogs(await res.json()); + setError(null); } catch (cause) { + if (silent) return; const detail = cause instanceof Error ? cause.message : ""; setError(detail ? `${t("logs.loadError")} ${detail}` : t("logs.loadError")); } finally { - if (!opts?.silent) setLoading(false); + if (!silent) setLoading(false); } }, [apiBase, t]); diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index b5c8f4797..0a4394a4c 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -627,7 +627,7 @@ function UsageWorkspaceSection({ }) { return ( - {title} + {title} {children} ); @@ -637,9 +637,12 @@ function UsageWorkspaceSection({ * Workspace layout for the Usage tab: a left rail lists the report sections * (Overview, Models, Providers, Coverage) and the main pane renders the selected * one. Section bodies reuse the same panel components as the classic stacked view. + * The shell mounts immediately (including while loading/empty) so workspace mode + * never flashes the classic EmptyState first. */ function UsageWorkspaceBody({ data, + loading, heatmap, weekBars, activeDays, @@ -653,7 +656,8 @@ function UsageWorkspaceBody({ locale, t, }: { - data: UsageResponse; + data: UsageResponse | null; + loading: boolean; heatmap: ReturnType; weekBars: UsageDay[]; activeDays: number; @@ -667,43 +671,53 @@ function UsageWorkspaceBody({ locale: Locale; t: TFn; }) { + const empty = !!data && data.summary.requests === 0; const sections = [ { id: "overview", label: t("usage.section.overview"), - meta: `${data.summary.requests}`, - body: ( + meta: data ? `${data.summary.requests}` : "—", + body: data ? ( <> > - ), + ) : null, }, { id: "models", label: t("usage.section.models"), - meta: `${data.models.length}`, - body: , + meta: data ? `${data.models.length}` : "—", + body: data + ? + : null, }, { id: "providers", label: t("usage.section.providers"), - meta: `${data.providers.length}`, - body: , + meta: data ? `${data.providers.length}` : "—", + body: data + ? + : null, }, { id: "coverage", label: t("usage.section.coverage"), - meta: formatPct(data.summary.coverageRatio), - body: , + meta: data ? formatPct(data.summary.coverageRatio) : "—", + body: data ? : null, }, ]; const selected = sections.find(s => s.id === selectedSection) ?? sections[0]; + const mainBody = loading && !data + ? + : empty + ? + : selected.body; return ( - - - {selected.body} + + {mainBody} @@ -811,13 +825,10 @@ export default function Usage({ apiBase, viewMode }: { apiBase: string; viewMode {t("common.retry")} - ) : loading && !data ? ( - - ) : data?.summary.requests === 0 ? ( - - ) : data && workspaceView ? ( + ) : workspaceView ? ( + ) : loading && !data ? ( + + ) : data?.summary.requests === 0 ? ( + ) : data ? ( <> diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index b4d9cf77d..99df03ee8 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -150,6 +150,7 @@ } /* ── Responsive ───────────────────────────────────────── */ +/* Keep @container and @media stacking rules in sync when changing breakpoints. */ @container usage-workspace (max-width: 720px) { .usage-workspace-root { diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx new file mode 100644 index 000000000..16905a2ac --- /dev/null +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, expect, jest, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import Logs from "../src/pages/Logs"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const sampleLog = { + requestId: "req-1", + timestamp: 1_700_000_000_000, + model: "gpt-test", + provider: "openai", + status: 200, + durationMs: 42, + usageStatus: "reported", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + displayMetrics: { + tokPerSecond: { kind: "unavailable", reason: "invalid_duration" }, + cost: { kind: "unavailable", reason: "price_unmatched" }, + }, +}; + +const updatedLog = { + ...sampleLog, + requestId: "req-2", + model: "gpt-updated", +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function installLayoutStubs(win: Window): void { + const proto = win.HTMLElement.prototype as unknown as HTMLElement; + Object.defineProperty(proto, "clientHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "clientWidth", { configurable: true, get() { return 1200; } }); + Object.defineProperty(proto, "offsetHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "offsetWidth", { configurable: true, get() { return 1200; } }); + Object.defineProperty(proto, "scrollHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "getBoundingClientRect", { + configurable: true, + value() { + return { + x: 0, y: 0, top: 0, left: 0, bottom: 800, right: 1200, width: 1200, height: 800, + toJSON() { return this; }, + }; + }, + }); + + class ResizeObserverStub { + #cb: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { this.#cb = cb; } + observe(target: Element) { + this.#cb( + [{ + target, + contentRect: { + x: 0, y: 0, top: 0, left: 0, bottom: 800, right: 1200, width: 1200, height: 800, + toJSON() { return this; }, + }, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + unobserve() {} + disconnect() {} + } + Object.defineProperty(globalThis, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); + Object.defineProperty(win, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#logs" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installLayoutStubs(testWindow); + jest.useFakeTimers({ now: 1_700_000_000_000 }); +}); + +afterEach(() => { + jest.useRealTimers(); + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mountLogs(): Promise<{ root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + // Let virtualizer observe + measure after first paint. + await act(async () => { + jest.advanceTimersByTime(0); + await Promise.resolve(); + }); + return { root, container }; +} + +async function flushMicrotasks(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function advanceSilentRefresh(): Promise { + await act(async () => { + jest.advanceTimersByTime(2000); + }); + await flushMicrotasks(); + await act(async () => { + jest.advanceTimersByTime(0); + await Promise.resolve(); + }); +} + +function clickRetry(container: HTMLElement): void { + const retry = [...container.querySelectorAll("button")].find(btn => btn.textContent?.trim() === "Retry"); + expect(retry).toBeTruthy(); + retry!.click(); +} + +function expectTableLoaded(container: HTMLElement, model: string): void { + expect(container.querySelector(".logs-table")).not.toBeNull(); + expect(container.textContent).not.toContain("No requests yet."); + expect(container.textContent).not.toContain("Could not load request logs."); + expect(container.textContent).toContain(model); +} + +test("Logs: initial failure shows error; silent failure keeps it; retry then recovers", async () => { + const calls: string[] = []; + let mode: "fail" | "ok" = "fail"; + + globalThis.fetch = (async (input) => { + const url = String(input); + calls.push(url); + if (!url.includes("/api/logs")) return new Response(null, { status: 404 }); + if (mode === "fail") return jsonResponse({ error: "down" }, 503); + return jsonResponse([sampleLog]); + }) as typeof fetch; + + const { root, container } = await mountLogs(); + await flushMicrotasks(); + + expect(container.textContent).toContain("Could not load request logs."); + expect(container.textContent).not.toContain("No requests yet."); + expect(container.textContent).not.toMatch(/\bLoading\b/); + const initialCalls = calls.filter(u => u.includes("/api/logs")).length; + expect(initialCalls).toBeGreaterThanOrEqual(1); + + await advanceSilentRefresh(); + expect(container.textContent).toContain("Could not load request logs."); + expect(container.textContent).not.toContain("No requests yet."); + expect(calls.filter(u => u.includes("/api/logs")).length).toBeGreaterThan(initialCalls); + + mode = "ok"; + await act(async () => { + clickRetry(container); + }); + await flushMicrotasks(); + await act(async () => { + jest.advanceTimersByTime(0); + await Promise.resolve(); + }); + + expectTableLoaded(container, "gpt-test"); + + await act(async () => { root.unmount(); }); +}); + +test("Logs: silent failure after successful load keeps the table and does not toggle loading or empty state", async () => { + let mode: "ok" | "fail" | "updated" = "ok"; + + globalThis.fetch = (async (input) => { + const url = String(input); + if (!url.includes("/api/logs")) return new Response(null, { status: 404 }); + if (mode === "fail") return jsonResponse({ error: "down" }, 503); + if (mode === "updated") return jsonResponse([updatedLog]); + return jsonResponse([sampleLog]); + }) as typeof fetch; + + const { root, container } = await mountLogs(); + await flushMicrotasks(); + expectTableLoaded(container, "gpt-test"); + + mode = "fail"; + await act(async () => { + jest.advanceTimersByTime(2000); + }); + const midFlightLoading = /\bLoading\b/.test(container.textContent ?? ""); + await flushMicrotasks(); + + expect(midFlightLoading).toBe(false); + expectTableLoaded(container, "gpt-test"); + expect(/\bLoading\b/.test(container.textContent ?? "")).toBe(false); + + mode = "updated"; + await advanceSilentRefresh(); + expectTableLoaded(container, "gpt-updated"); + + await act(async () => { root.unmount(); }); +}); + +test("Logs: silent success clears a previous error; later silent failure keeps the table", async () => { + let mode: "fail" | "ok" | "fail-again" = "fail"; + + globalThis.fetch = (async (input) => { + const url = String(input); + if (!url.includes("/api/logs")) return new Response(null, { status: 404 }); + if (mode === "ok") return jsonResponse([sampleLog]); + return jsonResponse({ error: "down" }, 503); + }) as typeof fetch; + + const { root, container } = await mountLogs(); + await flushMicrotasks(); + expect(container.textContent).toContain("Could not load request logs."); + + mode = "ok"; + await advanceSilentRefresh(); + expectTableLoaded(container, "gpt-test"); + + mode = "fail-again"; + await advanceSilentRefresh(); + expectTableLoaded(container, "gpt-test"); + + await act(async () => { root.unmount(); }); +}); + +test("Logs: disabling auto-refresh stops scheduled requests", async () => { + const urls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + urls.push(url); + if (!url.includes("/api/logs")) return new Response(null, { status: 404 }); + return jsonResponse([sampleLog]); + }) as typeof fetch; + + const { root, container } = await mountLogs(); + await flushMicrotasks(); + const afterInitial = urls.filter(u => u.includes("/api/logs")).length; + expect(afterInitial).toBe(1); + + const checkbox = container.querySelector('input[type="checkbox"]'); + expect(checkbox?.checked).toBe(true); + const autoRefreshLabel = checkbox!.closest("label"); + expect(autoRefreshLabel).not.toBeNull(); + await act(async () => { + autoRefreshLabel!.click(); + }); + await flushMicrotasks(); + expect(checkbox!.checked).toBe(false); + + // Effect re-runs once when autoRefresh flips (non-silent fetch), then must stop polling. + const afterDisable = urls.filter(u => u.includes("/api/logs")).length; + expect(afterDisable).toBeGreaterThanOrEqual(afterInitial); + expect(afterDisable).toBeLessThanOrEqual(afterInitial + 1); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + await flushMicrotasks(); + + expect(urls.filter(u => u.includes("/api/logs")).length).toBe(afterDisable); + + await act(async () => { root.unmount(); }); +}); + +test("Logs: switching to the Debug tab stops scheduled log requests", async () => { + const urls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + urls.push(url); + if (url.includes("/api/logs")) return jsonResponse([sampleLog]); + return jsonResponse({}); + }) as typeof fetch; + + const { root, container } = await mountLogs(); + await flushMicrotasks(); + const afterInitial = urls.filter(u => u.includes("/api/logs")).length; + expect(afterInitial).toBe(1); + + await act(async () => { + container.querySelector("#logs-tab-debug")!.click(); + }); + await flushMicrotasks(); + + // happy-dom may not emit hashchange on assignment; mirror the page listener. + if (container.querySelector("#logs-tab-debug")?.getAttribute("aria-selected") !== "true") { + await act(async () => { + window.location.hash = "logs/debug"; + window.dispatchEvent(new testWindow.Event("hashchange")); + }); + await flushMicrotasks(); + } + + expect(container.querySelector("#logs-tab-debug")?.getAttribute("aria-selected")).toBe("true"); + + await act(async () => { + jest.advanceTimersByTime(6000); + }); + await flushMicrotasks(); + + expect(urls.filter(u => u.includes("/api/logs")).length).toBe(afterInitial); + + await act(async () => { root.unmount(); }); +}); diff --git a/gui/tests/usage-workspace.test.ts b/gui/tests/usage-workspace.test.ts index a2e2506f1..af69b5787 100644 --- a/gui/tests/usage-workspace.test.ts +++ b/gui/tests/usage-workspace.test.ts @@ -25,14 +25,57 @@ test("Usage workspace uses section landmark and section rail", async () => { expect(src).toContain('t("usage.section.models")'); expect(src).toContain('t("usage.section.providers")'); expect(src).toContain('t("usage.section.coverage")'); + expect(src).toContain('
{t("usage.coverage.note")}