diff --git a/frontend/index.html b/frontend/index.html index 5cad5483..245f1571 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,9 +19,6 @@ - - -
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a57d8eb8..bf4985ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "cli2api-frontend", "version": "0.0.0", "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", "@heroui/react": "^3.2.4", "@heroui/styles": "^3.2.4", "@phosphor-icons/react": "^2.1.10", @@ -82,6 +84,24 @@ "node": ">=6.9.0" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz", + "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmmirror.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4ae013e7..48d7cf4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,8 @@ "sync": "npm run build && node ./scripts/sync-static.mjs" }, "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", "@heroui/react": "^3.2.4", "@heroui/styles": "^3.2.4", "@phosphor-icons/react": "^2.1.10", diff --git a/frontend/src/components/account/AccountCard.tsx b/frontend/src/components/account/AccountCard.tsx index f2647602..01fa25bc 100644 --- a/frontend/src/components/account/AccountCard.tsx +++ b/frontend/src/components/account/AccountCard.tsx @@ -186,15 +186,15 @@ export function AccountCard({ data-state={state} className="account-card overflow-hidden p-0" > - +
-
+
{account.name || account.id} - {provider} + {provider}
{account.remote_uid ? `UID ${account.remote_uid}` : account.id} @@ -245,7 +245,7 @@ export function AccountCard({
- + {account.quota ? ( @@ -358,7 +358,7 @@ export function AccountCard({ ) : null} {onCheckin ? ( -
+
@@ -381,7 +381,7 @@ export function AccountCard({
) : null} - + {onRefresh ? ( diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx index 1cd39f34..7e1b9903 100644 --- a/frontend/src/components/layout/AppHeader.tsx +++ b/frontend/src/components/layout/AppHeader.tsx @@ -24,7 +24,7 @@ export function AppHeader({ return (
-
+
@@ -32,7 +32,7 @@ export function AppHeader({
-

{title}

+

{title}

{desc}

diff --git a/frontend/src/components/layout/AppSidebar.tsx b/frontend/src/components/layout/AppSidebar.tsx index 7294304f..de9c1382 100644 --- a/frontend/src/components/layout/AppSidebar.tsx +++ b/frontend/src/components/layout/AppSidebar.tsx @@ -68,7 +68,7 @@ function NavList({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: ( {collapsed ? ( ) : ( - {group.label} + {group.label} )} {nav.filter((item) => item.section === group.key).map((item) => { const Icon = item.icon @@ -80,12 +80,12 @@ function NavList({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: ( onClick={onNavigate} aria-label={t(item.key)} title={collapsed ? t(item.key) : undefined} - className={({ isActive }) => `group/navitem relative flex items-center gap-3 rounded-xl py-2.5 text-[14px] transition-colors will-change-transform ${collapsed ? 'mx-auto w-10 justify-center px-0' : 'px-3'} ${isActive ? 'bg-surface-secondary font-semibold text-foreground' : 'font-medium text-muted hover:bg-surface-secondary hover:text-foreground'}`} + className={({ isActive }) => `group/navitem relative flex items-center gap-2.5 rounded-lg py-1.5 text-[13px] transition-colors will-change-transform ${collapsed ? 'mx-auto w-10 justify-center px-0' : 'px-3'} ${isActive ? 'bg-default font-medium text-foreground' : 'font-normal text-muted hover:bg-default hover:text-foreground'}`} onMouseDown={(event) => pressScale(event.currentTarget)} > {({ isActive }) => ( <> - + {!collapsed && {t(item.key)}} {collapsed && {t(item.key)}} diff --git a/frontend/src/components/ui/FilterToggle.tsx b/frontend/src/components/ui/FilterToggle.tsx index 30c8bb77..091a3200 100644 --- a/frontend/src/components/ui/FilterToggle.tsx +++ b/frontend/src/components/ui/FilterToggle.tsx @@ -67,11 +67,11 @@ export function FilterToggle({ }, [value, options]) return ( -
+
= { disabled: 'Disabled', delete: 'Delete', holdToDelete: 'Hold to delete', + trendHalfHint: 'Second half of the window vs the first half', toastRefreshingAccount: 'Refreshing account…', toastAccountRefreshed: 'Account refreshed', toastRefreshingCredits: 'Refreshing credits…', @@ -1386,6 +1387,7 @@ export const messages: Record = { disabled: '已停用', delete: '删除', holdToDelete: '按住删除', + trendHalfHint: '所选时间窗口后半段对比前半段', toastRefreshingAccount: '正在刷新账号…', toastAccountRefreshed: '账号已刷新', toastRefreshingCredits: '正在刷新积分…', diff --git a/frontend/src/index.css b/frontend/src/index.css index 643e621b..1d7271ca 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,58 +1,117 @@ @import "tailwindcss"; @import "@heroui/styles"; +/* Geist (latin subset only, ~52 KB total) is bundled into the binary so the + console no longer reaches out to Google Fonts; CJK falls back to the OS. */ +@font-face { + font-family: "Geist Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("@fontsource-variable/geist/files/geist-latin-wght-normal.woff2") format("woff2-variations"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Geist Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("@fontsource-variable/geist-mono/files/geist-mono-latin-wght-normal.woff2") format("woff2-variations"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + @theme { - --font-sans: "Outfit", "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, sans-serif; - --font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; + --font-sans: "Geist Variable", "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, sans-serif; + --font-mono: "Geist Mono Variable", "SFMono-Regular", Consolas, monospace; } -/* Softer field corners + visible borders: HeroUI inputs / textareas / - number-field groups / select triggers all read these vars. The stock - theme ships --field-border-width:0px and --field-border:transparent, - relying on a barely-visible shadow instead — fields look flat on both - light and dark surfaces, so draw a real border. */ +/* ========================================================================== + Studio grayscale theme (after arhamkhnz/next-shadcn-admin-dashboard). + Rules: zero-chroma neutrals, near-black / near-white primary, hairline + borders instead of heavy shadows, 10 px control radius / 14 px cards, + tabular numbers. Only HeroUI's own variables are redefined here, so every + HeroUI component picks the theme up without code changes. + ========================================================================== */ :root, .light, .default, [data-theme="light"], [data-theme="default"] { - --field-radius: 16px; + --radius: 0.625rem; + --field-radius: 0.5rem; + + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --surface: oklch(1 0 0); + --surface-foreground: var(--foreground); + --surface-secondary: oklch(0.985 0 0); + --surface-tertiary: oklch(0.97 0 0); + --overlay: oklch(1 0 0); + --overlay-foreground: var(--foreground); + --muted: oklch(0.556 0 0); + --default: oklch(0.97 0 0); + --default-foreground: oklch(0.205 0 0); + --segment: oklch(1 0 0); + --segment-foreground: oklch(0.145 0 0); + --border: oklch(0.922 0 0); + --separator: oklch(0.922 0 0); + --link: var(--foreground); + + --accent: oklch(0.205 0 0); + --accent-foreground: oklch(0.985 0 0); + --focus: oklch(0.708 0 0); + --accent-soft: oklch(0.97 0 0); + --accent-soft-hover: oklch(0.94 0 0); + --accent-soft-foreground: oklch(0.205 0 0); + + --field-background: oklch(1 0 0); --field-border-width: 1px; - --field-border: oklch(80% 0.008 286.32); - --field-border-hover: oklch(70% 0.012 285.9); - --field-border-focus: oklch(62% 0.04 285.9); + --field-border: oklch(0.922 0 0); + --field-border-hover: oklch(0.85 0 0); + --field-border-focus: oklch(0.708 0 0); + + --hairline: oklch(0.145 0 0 / 10%); + --surface-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --overlay-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06); + --field-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.04); } .dark, [data-theme="dark"] { - --field-radius: 16px; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --surface: oklch(0.205 0 0); + --surface-foreground: var(--foreground); + --surface-secondary: oklch(0.175 0 0); + --surface-tertiary: oklch(0.269 0 0); + --overlay: oklch(0.205 0 0); + --overlay-foreground: var(--foreground); + --muted: oklch(0.708 0 0); + --default: oklch(0.269 0 0); + --default-foreground: oklch(0.985 0 0); + --segment: oklch(0.32 0 0); + --segment-foreground: oklch(0.985 0 0); + --border: oklch(1 0 0 / 10%); + --separator: oklch(1 0 0 / 10%); + + --accent: oklch(0.922 0 0); + --accent-foreground: oklch(0.205 0 0); + --focus: oklch(0.556 0 0); + --accent-soft: oklch(0.269 0 0); + --accent-soft-hover: oklch(0.31 0 0); + --accent-soft-foreground: oklch(0.985 0 0); + + --field-background: oklch(1 0 0 / 4.5%); --field-border-width: 1px; - --field-border: oklch(38% 0.008 286.033); - --field-border-hover: oklch(47% 0.012 285.9); - --field-border-focus: oklch(58% 0.04 285.9); -} - -/* Chrome accent is Charcoal Ink. Signal Cyan stays on the C tip / scan only. - Dark inverts the fill so an ink button does not vanish on Off-Black Night. */ -:root, -.light, -.default, -[data-theme="light"], -[data-theme="default"] { - --accent: #18181b; - --accent-foreground: #fcfcfc; - --focus: var(--accent); - --accent-soft: color-mix(in oklab, var(--accent) 28%, transparent); - --accent-soft-hover: color-mix(in oklab, var(--accent) 36%, transparent); -} + --field-border: oklch(1 0 0 / 15%); + --field-border-hover: oklch(1 0 0 / 22%); + --field-border-focus: oklch(0.556 0 0); -.dark, -[data-theme="dark"] { - --accent: #fcfcfc; - --accent-foreground: #18181b; - --focus: var(--accent); - --accent-soft: color-mix(in oklab, var(--accent) 22%, transparent); - --accent-soft-hover: color-mix(in oklab, var(--accent) 30%, transparent); + --hairline: oklch(1 0 0 / 10%); + --surface-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.3); + --overlay-shadow: 0 8px 24px -6px rgb(0 0 0 / 0.5); + --field-shadow: none; } html, @@ -84,15 +143,7 @@ body { color: var(--foreground); } -/* HeroUI 3.2.4 Tabs.Indicator crashes (SharedElementTransition). Selected - tabs and pagination current-page otherwise stay unfilled / gray. Glue both - to accent-soft so selection matches ToggleButton / Switch / Radio. */ -.tabs__tab[data-selected="true"] { - background: var(--accent-soft); - color: var(--accent-soft-foreground); - box-shadow: none; -} - +/* Pagination current page uses the neutral selection fill. */ .pagination__link[data-active="true"] { --pagination-link-bg: var(--accent-soft); --pagination-link-bg-hover: var(--accent-soft-hover); @@ -344,3 +395,219 @@ body { transition-duration: 0.01ms !important; } } + +/* ========================================================================== + Studio component geometry. Kept in the components layer so per-element + Tailwind utilities (rounded-full, shadow-none …) still win. + ========================================================================== */ +@layer components { + /* Cards: 14 px corners, a hairline ring and an xs shadow, no border. */ + .card { + border-radius: calc(var(--radius) + 4px); + border: 0; + box-shadow: 0 0 0 1px var(--hairline), var(--surface-shadow); + } + + /* Controls: 8 px corners, compact heights (h-8 / h-7). */ + .button, + .toggle-button { + border-radius: calc(var(--radius) - 2px); + } + .button--sm, + .toggle-button--sm { + height: 1.75rem; + padding-inline: 0.625rem; + font-size: 0.8125rem; + } + .button--icon-only.button--sm { + width: 1.75rem; + padding: 0; + } + .button--md { + height: 2rem; + } + .button--outline, + .button--secondary { + box-shadow: 0 0 0 1px var(--border), var(--field-shadow); + } + .button--secondary { + background: var(--surface); + color: var(--foreground); + } + + /* Segmented groups: one muted track, items sit inside it. */ + .toggle-button-group { + gap: 0; + padding: 2px; + border-radius: calc(var(--radius) - 1px); + background: var(--default); + } + .toggle-button-group .toggle-button, + .toggle-button-group--horizontal .toggle-button:first-child, + .toggle-button-group--horizontal .toggle-button:last-child, + .toggle-button-group--horizontal .toggle-button:first-child:last-child { + border-radius: calc(var(--radius) - 3px); + } + .toggle-button-group .toggle-button { + background: transparent; + color: var(--muted); + } + .toggle-button-group .toggle-button[data-selected="true"] { + color: var(--foreground); + background: var(--segment); + box-shadow: 0 0 0 1px var(--hairline), var(--surface-shadow); + } + /* FilterToggle draws the track on its wrapper and one sliding pill. */ + .filter-toggle { + padding: 2px; + border-radius: calc(var(--radius) - 1px); + background: var(--default); + } + .filter-toggle .toggle-button-group { + padding: 0; + background: transparent; + } + .filter-toggle .toggle-button-group .toggle-button[data-selected="true"] { + background: transparent; + box-shadow: none; + } + .toggle-button-group__separator { + display: none; + } + + /* Tabs: same segmented track, the indicator is a raised white pane. */ + .tabs__list-container { + border-radius: calc(var(--radius) - 1px); + background: var(--default); + } + .tabs__tab { + height: 1.75rem; + border-radius: calc(var(--radius) - 3px); + font-size: 0.8125rem; + } + .tabs__tab[data-selected="true"] { + color: var(--foreground); + } + .tabs__indicator { + border-radius: calc(var(--radius) - 3px); + background: var(--segment); + box-shadow: 0 0 0 1px var(--hairline), var(--surface-shadow); + } + + /* Chips: small pills, h-5. */ + .chip { + border-radius: 999px; + padding-inline: 0.5rem; + font-size: 0.75rem; + line-height: 1.25rem; + } + + /* Overlays: thin ring + soft shadow instead of a heavy float. */ + .modal__dialog, + .popover, + .tooltip { + box-shadow: 0 0 0 1px var(--hairline), var(--overlay-shadow); + } + .modal__dialog { + border-radius: calc(var(--radius) + 4px); + } +} + +/* Numbers everywhere line up. */ +.tabular, +.mono, +.card [class*="text-2xl"], +.card [class*="text-3xl"] { + font-variant-numeric: tabular-nums; +} + +/* Filter toggle sliding pill (FilterToggle.tsx) matches the tab indicator. */ +.filter-toggle__pill { + border-radius: calc(var(--radius) - 3px); + background: var(--segment); + box-shadow: 0 0 0 1px var(--hairline), var(--surface-shadow); +} + +/* Trend chip (metric cards): solid ink for up, soft red for down. */ +.trend-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + height: 1.25rem; + padding: 0 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; + background: var(--accent); + color: var(--accent-foreground); +} +.trend-chip[data-tone="down"] { + background: color-mix(in oklab, var(--danger) 12%, transparent); + color: var(--danger); +} +.trend-chip[data-tone="flat"] { + background: var(--default); + color: var(--muted); +} + +/* Icon tile used by metric cards and section headers. */ +.icon-tile { + display: inline-grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + border-radius: calc(var(--radius) - 2px); + border: 1px solid var(--border); + background: var(--surface-tertiary); + color: var(--muted); +} + +/* Header toolbar: a flat hairline group instead of a floating pill. */ +@layer components { + .toolbar--attached { + gap: 0; + padding: 2px; + border-radius: calc(var(--radius) - 1px); + background: var(--surface); + box-shadow: 0 0 0 1px var(--border), var(--field-shadow); + } +} + +/* Tables (Studio data table): flat header on a muted strip, no column + ticks, hairline row dividers, no inner rounded card. */ +@layer components { + .table-root--primary { + padding: 0; + border-radius: 0; + background: transparent; + } + .table__header { + background: var(--surface-secondary); + } + .table__column { + padding-block: 0.5rem; + font-weight: 500; + } + .table__column::after { + content: none; + } + .table__body tr td { + border-radius: 0 !important; + border-bottom: 1px solid var(--separator); + background: var(--surface); + } + .table__body tr:last-child td { + border-bottom: 0; + } +} + +/* Data toolbar above a table (logs): one quiet strip holding search, + segmented filters and selects, like Studio's table toolbar. */ +.data-toolbar { + padding: 0.5rem; + border-radius: calc(var(--radius) + 2px); + border: 1px solid var(--border); + background: var(--surface-secondary); +} diff --git a/frontend/src/pages/AccessPage.tsx b/frontend/src/pages/AccessPage.tsx index bc7d4962..0bfaf36a 100644 --- a/frontend/src/pages/AccessPage.tsx +++ b/frontend/src/pages/AccessPage.tsx @@ -204,7 +204,7 @@ export function AccessPage() {
-

{t('apiPlayground')}

+

{t('apiPlayground')}

{t('apiPlaygroundHint')}

diff --git a/frontend/src/pages/KeysPage.tsx b/frontend/src/pages/KeysPage.tsx index dd533039..04935008 100644 --- a/frontend/src/pages/KeysPage.tsx +++ b/frontend/src/pages/KeysPage.tsx @@ -176,7 +176,7 @@ export function KeysPage() {
-

{t('navKeys')}

+

{t('navKeys')}

{t('keysLead')}

-

{t('navLogs')}

+

{t('navLogs')}

{t('logsLead')}

@@ -500,7 +500,7 @@ export function LogsPage() { {error ? : null} setTab(String(key) as PageTab)}> - + @@ -514,7 +514,7 @@ export function LogsPage() { -
+
-
+
points.reduce((total, point) => total + point[key], 0) + const firstHalf = traffic.series.slice(0, half) + const secondHalf = traffic.series.slice(half) + const change = (before: number, after: number) => (before > 0 ? (after - before) / before : null) + const requestTrend = change(sum(firstHalf, 'requests'), sum(secondHalf, 'requests')) + const firstRate = sum(firstHalf, 'requests') ? sum(firstHalf, 'ok') / sum(firstHalf, 'requests') : null + const secondRate = sum(secondHalf, 'requests') ? sum(secondHalf, 'ok') / sum(secondHalf, 'requests') : null + const successTrend = firstRate != null && secondRate != null ? secondRate - firstRate : null const metrics = [ - { label: t('metricRequests'), value: traffic.totals.requests as number | null, kind: 'compact' as const, detail: t('statsWindowHint', { window: t(hours === 1 ? 'statsWindow1h' : hours === 168 ? 'statsWindow7d' : 'statsWindow24h') }), ok: traffic.totals.requests > 0 }, - { label: t('metricSuccess'), value: traffic.totals.success_rate, kind: 'percent' as const, detail: `${traffic.totals.ok} ${t('logsFilterOk')} · ${traffic.totals.incomplete} ${t('logsFilterIncomplete')} · ${traffic.totals.error} ${t('logsFilterError')}`, ok: traffic.totals.requests === 0 || traffic.totals.success_rate >= 0.9 }, - { label: t('metricLatency'), value: traffic.latency.p95_ms ?? traffic.latency.avg_ms, kind: 'ms' as const, detail: `p50 ${formatLatency(traffic.latency.p50_ms)} · avg ${formatLatency(traffic.latency.avg_ms)}`, ok: traffic.latency.p95_ms == null || traffic.latency.p95_ms < 8000 }, - { label: t('metricTokens'), value: traffic.tokens.total, kind: 'compact' as const, detail: `${formatCompact(traffic.tokens.prompt)} / ${formatCompact(traffic.tokens.completion)}`, ok: true }, + { label: t('metricRequests'), value: traffic.totals.requests as number | null, kind: 'compact' as const, detail: t('statsWindowHint', { window: t(hours === 1 ? 'statsWindow1h' : hours === 168 ? 'statsWindow7d' : 'statsWindow24h') }), ok: traffic.totals.requests > 0, icon: , trend: requestTrend }, + { label: t('metricSuccess'), value: traffic.totals.success_rate, kind: 'percent' as const, detail: `${traffic.totals.ok} ${t('logsFilterOk')} · ${traffic.totals.incomplete} ${t('logsFilterIncomplete')} · ${traffic.totals.error} ${t('logsFilterError')}`, ok: traffic.totals.requests === 0 || traffic.totals.success_rate >= 0.9, icon: , trend: successTrend }, + { label: t('metricLatency'), value: traffic.latency.p95_ms ?? traffic.latency.avg_ms, kind: 'ms' as const, detail: `p50 ${formatLatency(traffic.latency.p50_ms)} · avg ${formatLatency(traffic.latency.avg_ms)}`, ok: traffic.latency.p95_ms == null || traffic.latency.p95_ms < 8000, icon: , trend: null as number | null }, + { label: t('metricTokens'), value: traffic.tokens.total, kind: 'compact' as const, detail: `${formatCompact(traffic.tokens.prompt)} / ${formatCompact(traffic.tokens.completion)}`, ok: true, icon: , trend: null as number | null }, ] if (loading && !overview) return @@ -154,7 +171,7 @@ export function OverviewPage() {
-

{t('homeDisplay')}

+

{t('homeDisplay')}

{t('homeLead')}

@@ -185,23 +202,22 @@ export function OverviewPage() { {error ? : null} -
- {metrics.map((metric, index) => ( -
-
{metric.label}
-
-
-
- {statsLoading ? : metric.value == null ? '—' : } -
-
{statsLoading ? : metric.detail}
+
+ {metrics.map((metric) => ( + +
+ {metric.icon} + +
+
{metric.label}
+
+
+ {statsLoading ? : metric.value == null ? '—' : }
- + {!statsLoading && metric.trend != null ? : null}
-
+
{statsLoading ? : metric.detail}
+ ))}
@@ -553,3 +569,14 @@ function RankList({
) } + +function TrendChip({ value, hint }: { value: number; hint: string }) { + const tone = Math.abs(value) < 0.005 ? 'flat' : value > 0 ? 'up' : 'down' + const Icon = tone === 'down' ? TrendDown : tone === 'up' ? TrendUp : ChartLineUp + return ( + + + {value > 0 ? '+' : ''}{(value * 100).toFixed(1)}% + + ) +} diff --git a/frontend/src/pages/ProvidersPage.tsx b/frontend/src/pages/ProvidersPage.tsx index 064fb320..58293625 100644 --- a/frontend/src/pages/ProvidersPage.tsx +++ b/frontend/src/pages/ProvidersPage.tsx @@ -327,7 +327,7 @@ export function ProvidersPage() {
-

{t('availableModels')}

+

{t('availableModels')}

{models.length ? shownLabel : t('noModelsYet')}

diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx index 1609126b..4df7de85 100644 --- a/frontend/src/pages/SystemPage.tsx +++ b/frontend/src/pages/SystemPage.tsx @@ -294,7 +294,7 @@ export function SystemPage() {
-

{t('systemSettingsTitle')}

+

{t('systemSettingsTitle')}

{t('systemSettingsLead')}