diff --git a/changelog/unreleased/usage-metrics.md b/changelog/unreleased/usage-metrics.md new file mode 100644 index 00000000..180afb41 --- /dev/null +++ b/changelog/unreleased/usage-metrics.md @@ -0,0 +1,11 @@ +### English + +- **Console** · The overview traffic chart switches between requests, tokens (input / output / cache read) and latency (p95 / p50). The stats series now carries per-bucket tokens and p50/p95 latency. +- **Console** · New Usage page (sidebar → Usage) with sortable tables by model, account and provider: requests, success rate, p95, tokens, cache write and credits, using the same 1h / 24h / 7d / 30d / custom windows as the overview. Rows open the matching logs. +- **API** · `/api/logs/stats` accepts `dim=model,account,provider` and `limit` (default 6, max 500), and its grouped rows add p95 latency, token, cache and credit totals. Calls without these parameters behave as before. `/api/logs/requests` accepts `provider`, and the overview's provider rankings now drill down to the logs. + +### 中文 + +- **界面** · 概览流量图可在请求、Tokens(输入 / 输出 / 缓存读取)与延迟(p95 / p50)之间切换;统计序列新增每个时间桶的 token 与 p50/p95 延迟。 +- **界面** · 新增“用量”页面(侧边栏 → 用量),按模型、账号、供应商展示可排序的完整表格:请求数、成功率、p95、Tokens、缓存写入与积分;时间窗口与概览一致(1 小时 / 24 小时 / 7 天 / 30 天 / 自定义),点击行可查看对应日志。 +- **API** · `/api/logs/stats` 支持 `dim=model,account,provider` 与 `limit`(默认 6,最大 500),分组结果新增 p95 延迟、token、缓存与积分合计;不带这些参数的请求行为不变。`/api/logs/requests` 支持 `provider` 筛选,概览的供应商排行也可下钻到日志。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 174456ab..30418f5b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { AccountsPage } from '@/pages/AccountsPage' import { LoginPage } from '@/pages/LoginPage' import { SystemPage } from '@/pages/SystemPage' import { LogsPage } from '@/pages/LogsPage' +import { UsagePage } from '@/pages/UsagePage' import { KeysPage } from '@/pages/KeysPage' export default function App() { @@ -31,6 +32,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/logs.ts b/frontend/src/api/logs.ts index c9e490ca..9f2b297a 100644 --- a/frontend/src/api/logs.ts +++ b/frontend/src/api/logs.ts @@ -112,6 +112,7 @@ export type RequestStatsTotals = { canceled: number streaming: number success_rate: number + credits?: number } export type RequestStatsLatency = { @@ -125,6 +126,7 @@ export type RequestStatsTokens = { prompt: number completion: number cache_read: number + cache_write?: number total: number } @@ -139,6 +141,12 @@ export type RequestStatsNamed = { ok: number error: number latency_avg_ms?: number | null + latency_p95_ms?: number | null + prompt_tokens?: number + completion_tokens?: number + cache_read_tokens?: number + cache_write_tokens?: number + credits?: number } export type RequestStatsPoint = { @@ -146,8 +154,15 @@ export type RequestStatsPoint = { requests: number ok: number error: number + prompt_tokens?: number + completion_tokens?: number + cache_read_tokens?: number + latency_p50_ms?: number | null + latency_p95_ms?: number | null } +export type RequestStatsDim = 'model' | 'account' | 'provider' + export type RequestStats = { window: RequestStatsWindow totals: RequestStatsTotals @@ -167,6 +182,7 @@ export type RequestLogQuery = { stream?: boolean error_kind?: string model?: string + provider?: string id?: string q?: string from?: string @@ -175,8 +191,15 @@ export type RequestLogQuery = { offset?: number } -export function fetchRequestStats(query: { hours?: number; from?: string; to?: string } = {}) { +/** + * GET /api/logs/stats. Without dim/limit the response keeps the overview + * contract (all three grouped lists, top 6 each). dim picks the grouped + * lists to compute; limit caps their rows (server max 500). + */ +export function fetchRequestStats(query: { hours?: number; from?: string; to?: string; dim?: RequestStatsDim[]; limit?: number } = {}) { const params = new URLSearchParams() + if (query.dim?.length) params.set('dim', query.dim.join(',')) + if (query.limit != null) params.set('limit', String(query.limit)) if (query.hours != null) params.set('hours', String(query.hours)) if (query.from) params.set('from', query.from) if (query.to) params.set('to', query.to) @@ -191,6 +214,7 @@ export function fetchRequestLogs(query: RequestLogQuery = {}) { if (query.stream != null) params.set('stream', query.stream ? '1' : '0') if (query.error_kind) params.set('error_kind', query.error_kind) if (query.model) params.set('model', query.model) + if (query.provider) params.set('provider', query.provider) if (query.id) params.set('id', query.id) if (query.q) params.set('q', query.q) if (query.from) params.set('from', query.from) diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 84fe3bf6..4bb22a70 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -11,6 +11,7 @@ const routeKey: Record = { '/providers': 'providers', '/access': 'access', '/accounts': 'accounts', + '/usage': 'usage', '/logs': 'logs', '/keys': 'keys', '/system': 'system', diff --git a/frontend/src/components/layout/AppSidebar.tsx b/frontend/src/components/layout/AppSidebar.tsx index 754834c9..528a29ed 100644 --- a/frontend/src/components/layout/AppSidebar.tsx +++ b/frontend/src/components/layout/AppSidebar.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { Drawer, Button, Chip, Separator, Tooltip } from '@heroui/react' -import { Cube, Gauge, GearSix, Key, Lightning, Scroll, SidebarSimple, UsersThree, X } from '@phosphor-icons/react' +import { ChartBar, Cube, Gauge, GearSix, Key, Lightning, Scroll, SidebarSimple, UsersThree, X } from '@phosphor-icons/react' import { SidebarUpdateItem } from '@/components/layout/SidebarUpdateItem' import { BrandMark } from '@/components/BrandMark' import { SkeletonBlock } from '@/components/ui/PageSkeletons' @@ -12,6 +12,7 @@ import { useOverview } from '@/hooks/useOverview' const nav = [ { to: '/', key: 'navOverview', icon: Gauge, section: 'workspace', end: true }, + { to: '/usage', key: 'navUsage', icon: ChartBar, section: 'workspace' }, { to: '/accounts', key: 'navAccounts', icon: UsersThree, section: 'workspace' }, { to: '/providers', key: 'navProviders', icon: Cube, section: 'develop' }, { to: '/access', key: 'navAccess', icon: Lightning, section: 'develop' }, diff --git a/frontend/src/components/overview/StatsWindowControls.tsx b/frontend/src/components/overview/StatsWindowControls.tsx new file mode 100644 index 00000000..bf38376d --- /dev/null +++ b/frontend/src/components/overview/StatsWindowControls.tsx @@ -0,0 +1,53 @@ +import { FilterToggle } from '@/components/ui/FilterToggle' +import { TimeRangePicker, type DateRangeValue } from '@/components/ui/TimeRangePicker' +import { STATS_WINDOW_LABEL, type StatsWindow } from '@/lib/statsWindow' + +/** + * 1h / 24h / 7d / 30d / custom window toggle plus the custom from/to picker, + * shared by the overview and usage pages. onBusy fires when a change will + * trigger a new load (so the caller can show its skeleton). + */ +export function StatsWindowControls({ + value, + onChange, + custom, + onCustomChange, + onBusy, + t, +}: { + value: StatsWindow + onChange: (next: StatsWindow) => void + custom: DateRangeValue | null + onCustomChange: (next: DateRangeValue | null) => void + onBusy?: () => void + t: (key: string) => string +}) { + return ( + <> + { + const window = next as StatsWindow + if (value === window) return + if (window !== 'custom' || custom) onBusy?.() + onChange(window) + }} + ariaLabel={t('statsTraffic')} + options={(Object.keys(STATS_WINDOW_LABEL) as StatsWindow[]).map((id) => ({ id, label: t(STATS_WINDOW_LABEL[id]) }))} + /> + {value === 'custom' ? ( + { + if (next) onBusy?.() + onCustomChange(next) + }} + ariaLabel={t('statsWindowCustom')} + fromLabel={t('logsTimeFrom')} + toLabel={t('logsTimeTo')} + /> + ) : null} + + ) +} diff --git a/frontend/src/components/overview/TrafficChart.tsx b/frontend/src/components/overview/TrafficChart.tsx index 5a1e7f1e..638c7d61 100644 --- a/frontend/src/components/overview/TrafficChart.tsx +++ b/frontend/src/components/overview/TrafficChart.tsx @@ -13,7 +13,8 @@ import { } from 'recharts' import type { RequestStatsPoint } from '@/api/logs' import { TrafficChartEmpty } from '@/components/overview/TrafficChartEmpty' -import { formatCompact } from '@/lib/format' +import { FilterToggle } from '@/components/ui/FilterToggle' +import { formatCompact, formatLatency } from '@/lib/format' import { TYPE_SCALE_PX } from '@/lib/typeScale' function hourLabel(value: string, daily: boolean, lang: 'en' | 'zh') { @@ -33,22 +34,54 @@ function niceMax(peak: number) { return nice * base } -type ChartRow = RequestStatsPoint & { okCount: number; errorCount: number } +export type TrafficMetric = 'requests' | 'tokens' | 'latency' + +type ChartRow = RequestStatsPoint & { + okCount: number + errorCount: number + inputTokens: number + outputTokens: number + cacheTokens: number + p50: number | null + p95: number | null +} + +type SeriesSpec = { key: keyof ChartRow; label: string; color: string; area?: boolean; width?: number; opacity?: number } + +/** Derives the plotted fields for every metric from one stats point. */ +function toChartRow(point: RequestStatsPoint): ChartRow { + const okCount = Math.max(0, point.ok ?? (point.requests - point.error)) + const errorCount = Math.min(Math.max(0, point.requests - okCount), Math.max(0, point.requests)) + return { + ...point, + okCount, + errorCount, + inputTokens: point.prompt_tokens ?? 0, + outputTokens: point.completion_tokens ?? 0, + cacheTokens: point.cache_read_tokens ?? 0, + p50: point.latency_p50_ms ?? null, + p95: point.latency_p95_ms ?? null, + } +} function TrafficTooltip({ active, payload, daily, lang, - okLabel, - errorLabel, + specs, + format, + totalLabel, + total, }: { active?: boolean payload?: Array<{ payload: ChartRow }> daily: boolean lang: 'en' | 'zh' - okLabel: string - errorLabel: string + specs: SeriesSpec[] + format: (value: number | null) => string + totalLabel?: string + total?: (row: ChartRow) => number }) { if (!active || !payload?.[0]) return null const point = payload[0].payload @@ -56,19 +89,18 @@ function TrafficTooltip({
{hourLabel(point.at, daily, lang)}
-
- {okLabel} - {point.okCount} -
-
- {errorLabel} - {/* 与红色折线同口径:未成功数(error + incomplete + canceled) */} - {point.errorCount} -
-
- total - {point.requests} -
+ {specs.map((spec) => ( +
+ {spec.label} + {format(point[spec.key] as number | null)} +
+ ))} + {total && totalLabel ? ( +
+ {totalLabel} + {format(total(point))} +
+ ) : null}
) @@ -82,16 +114,33 @@ export function TrafficChart({ errorLabel, onSelectRange, selectHint, + labels, + initialMetric = 'requests', }: { series: RequestStatsPoint[] lang: 'en' | 'zh' emptyLabel: string okLabel: string errorLabel: string + /** Labels for the metric switch and its series. */ + labels?: { + requests: string + tokens: string + latency: string + input: string + output: string + cacheRead: string + p50: string + p95: string + total: string + } + /** Initial metric (the switch is internal). */ + initialMetric?: TrafficMetric /** Called with an ISO [from, to) range after the user drag-selects buckets. */ onSelectRange?: (from: string, to: string) => void selectHint?: string }) { + const [metric, setMetric] = useState(initialMetric) const [dragStart, setDragStart] = useState(null) const [dragEnd, setDragEnd] = useState(null) // Draw the lines in once per mount; background refreshes must not replay it. @@ -105,20 +154,38 @@ export function TrafficChart({ // 不能只用后端 error 字段:canceled(客户端断开)等状态会被顶掉, // 导致 tooltip 出现 total 37 / 成功 1 / 失败 0、红线却贴底的缺口。 // 与概览卡片"成功率 = ok/total"的口径保持一致,红线垫平剩余部分。 - const data = useMemo(() => series.map((point) => { - const okCount = Math.max(0, point.ok ?? (point.requests - point.error)) - const errorCount = Math.min(Math.max(0, point.requests - okCount), Math.max(0, point.requests)) - return { - ...point, - okCount, - errorCount, - } - }), [series]) + const data = useMemo(() => series.map(toChartRow), [series]) const first = data[0] const last = data[data.length - 1] const span = first && last ? Date.parse(last.at) - Date.parse(first.at) : 0 const daily = span > 48 * 60 * 60 * 1000 - const peak = Math.max(0, ...data.map((point) => point.requests)) + const l = labels ?? { + requests: 'Requests', tokens: 'Tokens', latency: 'p95 latency', + input: 'input', output: 'output', cacheRead: 'cache read', p50: 'p50', p95: 'p95', total: 'total', + } + const specs: SeriesSpec[] = metric === 'tokens' + ? [ + { key: 'inputTokens', label: l.input, color: 'var(--foreground)', area: true, width: 1.5, opacity: 0.75 }, + { key: 'outputTokens', label: l.output, color: 'var(--success)', area: true, width: 1.5 }, + { key: 'cacheTokens', label: l.cacheRead, color: 'var(--muted)', width: 1.25, opacity: 0.8 }, + ] + : metric === 'latency' + ? [ + { key: 'p95', label: l.p95, color: 'var(--warning)', area: true, width: 1.75 }, + { key: 'p50', label: l.p50, color: 'var(--muted)', width: 1.25, opacity: 0.85 }, + ] + : [ + { key: 'okCount', label: okLabel, color: 'var(--success)', area: true, width: 1.75 }, + { key: 'errorCount', label: errorLabel, color: 'var(--danger)', area: true, width: 1.25, opacity: 0.7 }, + ] + const format = (value: number | null) => (value == null ? '—' : metric === 'latency' ? formatLatency(value) : formatCompact(value)) + const total = metric === 'requests' + ? (row: ChartRow) => row.requests + : metric === 'tokens' + ? (row: ChartRow) => row.inputTokens + row.outputTokens + : undefined + const values = data.flatMap((row) => specs.map((spec) => Number(row[spec.key] ?? 0))) + const peak = metric === 'requests' ? Math.max(0, ...data.map((point) => point.requests)) : Math.max(0, ...values) const yMax = niceMax(peak) const hasTraffic = data.some((point) => point.requests > 0) const signature = data.map((point) => `${point.at}:${point.requests}:${point.error}`).join('|') @@ -209,6 +276,18 @@ export function TrafficChart({ return (
+
+ setMetric(next as TrafficMetric)} + ariaLabel={l.requests} + options={[ + { id: 'requests', label: l.requests }, + { id: 'tokens', label: l.tokens }, + { id: 'latency', label: l.latency }, + ]} + /> +
{ setDragStart(null); setDragEnd(null) } : undefined} > - - - - - - - - + {specs.filter((spec) => spec.area).map((spec) => ( + + + + + ))} hourLabel(value, daily, lang)} /> formatCompact(value)} + tickFormatter={(value: number) => (metric === 'latency' ? formatLatency(value) : formatCompact(value))} /> } - /> - {/* 面积:先画成功(较大,作为底层),再画失败(叠加在底部,避免被完全遮住) */} - - - - } /> + {specs.filter((spec) => spec.area).map((spec) => ( + + ))} + {specs.map((spec) => ( + + ))} {dragStart && dragEnd ? ( ) : null} @@ -312,12 +374,13 @@ export function TrafficChart({
- {okLabel} - {errorLabel} + {specs.map((spec) => ( + {spec.label} + ))}
{onSelectRange && selectHint ? {selectHint} : null} - {formatCompact(peak)} peak + {format(peak)} peak
diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 3487af17..6148241f 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -42,6 +42,26 @@ export const messages: Record = { checkinScheduleHint: 'Uses the displayed timezone, normally within about 90 seconds of the chosen time; sequential account processing can take longer. Earlier failures retry once around 21:00. Disabled campaigns are skipped for the day. Chat is unaffected.', checkinTimeInvalid: 'Enter a valid check-in time (HH:mm).', checkinRecordSkipped: 'Activity unavailable', + navUsage: 'Usage', + usageTitle: 'Usage', + usageLead: 'Requests, latency, tokens and credits by model, account and provider.', + usageByModel: 'Models', + usageByAccount: 'Accounts', + usageByProvider: 'Providers', + usageColRequests: 'Requests', + usageColSuccess: 'Success', + usageColTokens: 'Tokens', + usageColCacheWrite: 'Cache write', + usageColCredits: 'Credits', + usageRowCount: '{count} rows', + usageEmpty: 'No requests in this window.', + chartMetricRequests: 'Requests', + chartMetricTokens: 'Tokens', + chartMetricLatency: 'p95 latency', + chartInput: 'Input', + chartOutput: 'Output', + chartCacheRead: 'Cache read', + chartTotal: 'Total', navLogs: 'Logs', navKeys: 'API keys', navSystem: 'System settings', @@ -177,6 +197,7 @@ export const messages: Record = { providers: { title: 'Models', desc: 'Models from signed-in accounts, plus per-model defaults for context and reasoning.' }, access: { title: 'Access', desc: 'Copy Base URL and run a quick Chat Completions check.' }, accounts: { title: 'Accounts', desc: 'One login per account. Browser sign-in first; PAT only when needed.' }, + usage: { title: 'Usage', desc: 'Usage by model, account and provider.' }, logs: { title: 'Logs', desc: 'Recent proxy requests and process output.' }, keys: { title: 'API keys', desc: 'Create client keys and limit them to selected providers.' }, system: { title: 'System settings', desc: 'Cross-provider routing, managed updates, SQLite protection, and the console API key.' }, @@ -216,6 +237,7 @@ export const messages: Record = { logsFilterError: 'Error', logsFilterCanceled: 'Canceled', logsFilterAccountAll: 'All accounts', + logsFilterProviderAll: 'All providers', logsFilterModelAll: 'All models', logsFilterKindAll: 'All errors', logsFilterStreamAll: 'All modes', @@ -792,6 +814,26 @@ export const messages: Record = { checkinScheduleHint: '按显示的时区执行,通常在设定时间后的约 90 秒内开始,多账号顺序处理可能延后;较早的失败会在约 21:00 重试一次,活动未开放则当天跳过。签到不影响聊天。', checkinTimeInvalid: '请输入有效的签到时间(HH:mm)。', checkinRecordSkipped: '活动未开放', + navUsage: '用量', + usageTitle: '用量', + usageLead: '按模型、账号与供应商查看请求、延迟、token 与积分。', + usageByModel: '模型', + usageByAccount: '账号', + usageByProvider: '供应商', + usageColRequests: '请求', + usageColSuccess: '成功率', + usageColTokens: 'Tokens', + usageColCacheWrite: '缓存写入', + usageColCredits: '积分', + usageRowCount: '{count} 行', + usageEmpty: '该时间窗口内没有请求。', + chartMetricRequests: '请求', + chartMetricTokens: 'Tokens', + chartMetricLatency: 'p95 延迟', + chartInput: '输入', + chartOutput: '输出', + chartCacheRead: '缓存读取', + chartTotal: '合计', navLogs: '日志', navKeys: 'API 密钥', navSystem: '系统设置', @@ -927,6 +969,7 @@ export const messages: Record = { providers: { title: '模型', desc: '已登录账号可用的模型,以及每个模型的上下文和推理默认值。' }, access: { title: '接入', desc: '复制 Base URL,并快速验证 Chat Completions。' }, accounts: { title: '账号', desc: '一个账号一个登录态。先走浏览器登录;浏览器不方便再用 PAT。' }, + usage: { title: '用量', desc: '按模型、账号与供应商统计用量。' }, logs: { title: '日志', desc: '查看近期代理请求和进程输出。' }, keys: { title: 'API 密钥', desc: '创建客户端密钥,并限制可用的供应商。' }, system: { title: '系统设置', desc: '管理跨 Provider 路由、系统更新、SQLite 保护和控制台管理员密钥。' }, @@ -966,6 +1009,7 @@ export const messages: Record = { logsFilterError: '失败', logsFilterCanceled: '取消', logsFilterAccountAll: '全部账号', + logsFilterProviderAll: '全部供应商', logsFilterModelAll: '全部模型', logsFilterKindAll: '全部错误', logsFilterStreamAll: '全部模式', diff --git a/frontend/src/lib/logsLink.ts b/frontend/src/lib/logsLink.ts new file mode 100644 index 00000000..6f5ba51d --- /dev/null +++ b/frontend/src/lib/logsLink.ts @@ -0,0 +1,16 @@ +/** + * /logs link with filters for a window. The logs picker is minute-granular and + * rounds the end up to :59.999, so the end is passed as the minute before its + * ceiling to keep the round trip exact. + */ +export function logsHref(filters: Record, from: string, to: string) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(filters)) if (value) params.set(key, value) + const fromMs = Date.parse(from) + const toMs = Date.parse(to) + if (Number.isFinite(fromMs) && Number.isFinite(toMs)) { + params.set('from', new Date(Math.floor(fromMs / 60_000) * 60_000).toISOString()) + params.set('to', new Date(Math.ceil(toMs / 60_000) * 60_000 - 60_000).toISOString()) + } + return `/logs?${params}` +} diff --git a/frontend/src/lib/statsWindow.ts b/frontend/src/lib/statsWindow.ts new file mode 100644 index 00000000..efa4f03d --- /dev/null +++ b/frontend/src/lib/statsWindow.ts @@ -0,0 +1,25 @@ +import { dateValueToISO, type DateRangeValue } from '@/components/ui/TimeRangePicker' + +export type StatsWindow = '1' | '24' | '168' | '720' | 'custom' + +export const STATS_WINDOW_LABEL: Record = { + '1': 'statsWindow1h', + '24': 'statsWindow24h', + '168': 'statsWindow7d', + '720': 'statsWindow30d', + custom: 'statsWindowCustom', +} + +/** Stats query for a window. 30d and custom go through from/to: the backend only accepts hours=1|24|168. */ +export function statsQuery(window: StatsWindow, custom: DateRangeValue | null) { + if (window === 'custom') { + const from = dateValueToISO(custom?.start) + const to = dateValueToISO(custom?.end, true) + return from && to ? { from, to } : null + } + if (window === '720') { + const to = new Date() + return { from: new Date(to.getTime() - 30 * 24 * 3600 * 1000).toISOString(), to: to.toISOString() } + } + return { hours: Number(window) } +} diff --git a/frontend/src/pages/LogsPage.tsx b/frontend/src/pages/LogsPage.tsx index 2d799f26..5a8114ec 100644 --- a/frontend/src/pages/LogsPage.tsx +++ b/frontend/src/pages/LogsPage.tsx @@ -39,7 +39,7 @@ import { LogsPageSkeleton, LogsRequestListSkeleton, LogsRuntimeListSkeleton, Ske import { SearchBar } from '@/components/ui/SearchBar' import { dateValueToISO, isoToDateValue, TimeRangePicker, type DateRangeValue } from '@/components/ui/TimeRangePicker' import { useI18n } from '@/hooks/useI18n' -import { accountProviderLabel } from '@/lib/provider' +import { accountProviderFamilyLabel, accountProviderLabel } from '@/lib/provider' type PageTab = 'requests' | 'runtime' type RequestFilter = 'all' | 'ok' | 'incomplete' | 'error' | 'canceled' @@ -169,6 +169,7 @@ type UrlFilters = { requestId: string accountFilter: string modelFilter: string + providerFilter: string streamFilter: StreamFilter errorKind: ErrorKindFilter timeRange: TimeRange @@ -188,6 +189,7 @@ function parseLogsUrl(params: URLSearchParams): UrlFilters { requestId: params.get('id') ?? '', accountFilter: params.get('account') ?? '', modelFilter: params.get('model') ?? '', + providerFilter: params.get('provider') ?? '', streamFilter: stream === 'stream' || stream === 'true' ? 'stream' : stream === 'sync' || stream === 'false' ? 'sync' : 'all', errorKind: pick(params.get('error_kind'), ERROR_KINDS, 'all'), timeRange: custom ? 'custom' : pick(params.get('range'), TIME_RANGES, '1h'), @@ -201,6 +203,7 @@ function buildLogsUrl(filters: UrlFilters) { if (filters.requestId) params.set('id', filters.requestId) if (filters.accountFilter) params.set('account', filters.accountFilter) if (filters.modelFilter) params.set('model', filters.modelFilter) + if (filters.providerFilter) params.set('provider', filters.providerFilter) if (filters.streamFilter !== 'all') params.set('stream', filters.streamFilter) if (filters.errorKind !== 'all') params.set('error_kind', filters.errorKind) const from = filters.timeRange === 'custom' ? dateValueToISO(filters.customRange?.start) : undefined @@ -241,6 +244,7 @@ export function LogsPage() { const [accountFilter, setAccountFilter] = useState(initialUrl.accountFilter) const [runtimeAccount, setRuntimeAccount] = useState('') const [modelFilter, setModelFilter] = useState(initialUrl.modelFilter) + const [providerFilter, setProviderFilter] = useState(initialUrl.providerFilter) const [streamFilter, setStreamFilter] = useState(initialUrl.streamFilter) const [errorKind, setErrorKind] = useState(initialUrl.errorKind) const [timeRange, setTimeRange] = useState(initialUrl.timeRange) @@ -266,7 +270,7 @@ export function LogsPage() { // URL <-> filter controls. External URL changes (back/forward, drill-down // links) are applied to the controls; control changes replace the URL. const searchString = searchParams.toString() - const urlString = buildLogsUrl({ requestFilter, requestId, accountFilter, modelFilter, streamFilter, errorKind, timeRange, customRange }) + const urlString = buildLogsUrl({ requestFilter, requestId, accountFilter, modelFilter, providerFilter, streamFilter, errorKind, timeRange, customRange }) const lastUrlRef = useRef(searchString) useEffect(() => { if (searchString === lastUrlRef.current) return @@ -276,6 +280,7 @@ export function LogsPage() { setRequestId(next.requestId) setAccountFilter(next.accountFilter) setModelFilter(next.modelFilter) + setProviderFilter(next.providerFilter) setStreamFilter(next.streamFilter) setErrorKind(next.errorKind) setTimeRange(next.timeRange) @@ -331,6 +336,15 @@ export function LogsPage() { () => (accounts || []).map((account) => ({ id: account.id, label: account.name || account.id })), [accounts], ) + const providerOptions = useMemo(() => { + const ids = new Set() + for (const account of accounts || []) if (account.provider) ids.add(account.provider) + if (providerFilter) ids.add(providerFilter) + return [ + { id: '', label: t('logsFilterProviderAll') }, + ...[...ids].sort().map((id) => ({ id, label: accountProviderFamilyLabel(id, t) })), + ] + }, [accounts, providerFilter, t]) const modelOptions = useMemo(() => { const seen = new Map() for (const model of models || []) { @@ -353,6 +367,7 @@ export function LogsPage() { || requestFilter !== 'all' || accountFilter || modelFilter + || providerFilter || streamFilter !== 'all' || errorKind !== 'all' || timeRange !== '1h', @@ -363,6 +378,7 @@ export function LogsPage() { requestId, accountFilter, modelFilter, + providerFilter, streamFilter, errorKind, timeRange, @@ -408,6 +424,7 @@ export function LogsPage() { id: requestId || undefined, account: accountFilter || undefined, model: modelFilter || undefined, + provider: providerFilter || undefined, stream: streamFilter === 'all' ? undefined : streamFilter === 'stream', error_kind: errorKind === 'all' ? undefined : errorKind, from: range.from, @@ -426,7 +443,7 @@ export function LogsPage() { setBooted(true) } } - }, [requestFilter, requestId, accountFilter, modelFilter, streamFilter, errorKind, timeRange, customRange, currentPage, pageSize]) + }, [requestFilter, requestId, accountFilter, modelFilter, providerFilter, streamFilter, errorKind, timeRange, customRange, currentPage, pageSize]) const loadRuntime = useCallback(async (quiet = false) => { if (!quiet) setLoading(true) @@ -489,6 +506,7 @@ export function LogsPage() { setRequestFilter('all') setAccountFilter('') setModelFilter('') + setProviderFilter('') setStreamFilter('all') setErrorKind('all') setTimeRange('1h') @@ -618,6 +636,12 @@ export function LogsPage() { searchPlaceholder={t('logsSearchModel')} emptyLabel={t('logsNoFilterOptions')} /> + import('@/components/overview/TrafficChart').then((mod) => ({ default: mod.TrafficChart }))) -import { FilterToggle } from '@/components/ui/FilterToggle' -import { dateValueToISO, TimeRangePicker, type DateRangeValue } from '@/components/ui/TimeRangePicker' +import { StatsWindowControls } from '@/components/overview/StatsWindowControls' +import { STATS_WINDOW_LABEL, statsQuery, type StatsWindow } from '@/lib/statsWindow' +import type { DateRangeValue } from '@/components/ui/TimeRangePicker' import { PageAlert } from '@/components/ui/PageAlert' import { OverviewPageSkeleton, RankListSkeleton, SkeletonBlock, TrafficChartSkeleton } from '@/components/ui/PageSkeletons' import { useI18n } from '@/hooks/useI18n' +import { logsHref } from '@/lib/logsLink' import { useOverview } from '@/hooks/useOverview' import { fetchAccounts, fetchModels } from '@/api/overview' import { formatBytes, formatCompact, formatLatency, formatPercent } from '@/lib/format' @@ -38,7 +40,6 @@ import { accountProviderFamilyLabel, accountProviderLabel } from '@/lib/provider import { ProviderMark } from '@/components/ProviderMark' type AccountRow = NonNullable[number] -type StatsWindow = '1' | '24' | '168' | '720' | 'custom' const STATS_REFRESH_MS = 30_000 @@ -55,37 +56,6 @@ const EMPTY_STATS: RequestStats = { series: [], } -/** Stats query for a window. 30d and custom go through from/to: the backend only accepts hours=1|24|168. */ -function statsQuery(window: StatsWindow, custom: DateRangeValue | null) { - if (window === 'custom') { - const from = dateValueToISO(custom?.start) - const to = dateValueToISO(custom?.end, true) - return from && to ? { from, to } : null - } - if (window === '720') { - const to = new Date() - return { from: new Date(to.getTime() - 30 * 24 * 3600 * 1000).toISOString(), to: to.toISOString() } - } - return { hours: Number(window) } -} - -/** - * /logs link with filters for a window. The logs picker is minute-granular and - * rounds the end up to :59.999, so the end is passed as the minute before its - * ceiling to keep the round trip exact. - */ -function logsHref(filters: Record, from: string, to: string) { - const params = new URLSearchParams() - for (const [key, value] of Object.entries(filters)) if (value) params.set(key, value) - const fromMs = Date.parse(from) - const toMs = Date.parse(to) - if (Number.isFinite(fromMs) && Number.isFinite(toMs)) { - params.set('from', new Date(Math.floor(fromMs / 60_000) * 60_000).toISOString()) - params.set('to', new Date(Math.ceil(toMs / 60_000) * 60_000 - 60_000).toISOString()) - } - return `/logs?${params}` -} - function familyLabel(provider: string | undefined, t: (key: string) => string) { if (!provider || provider === '(unknown)') return t('statsUnknown') return accountProviderFamilyLabel(provider, t) @@ -291,7 +261,7 @@ export function OverviewPage() { 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({ '1': 'statsWindow1h', '24': 'statsWindow24h', '168': 'statsWindow7d', '720': 'statsWindow30d', custom: 'statsWindowCustom' }[statsWindow]) }), ok: traffic.totals.requests > 0, icon: , trend: requestTrend }, + { label: t('metricRequests'), value: traffic.totals.requests as number | null, kind: 'compact' as const, detail: t('statsWindowHint', { window: t(STATS_WINDOW_LABEL[statsWindow]) }), 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 }, @@ -321,36 +291,14 @@ export function OverviewPage() {

{t('homeLead')}

- { - const value = next as StatsWindow - if (statsWindow === value) return - if (value !== 'custom' || customRange) setStatsLoading(true) - setStatsWindow(value) - }} - ariaLabel={t('statsTraffic')} - options={[ - { id: '1', label: t('statsWindow1h') }, - { id: '24', label: t('statsWindow24h') }, - { id: '168', label: t('statsWindow7d') }, - { id: '720', label: t('statsWindow30d') }, - { id: 'custom', label: t('statsWindowCustom') }, - ]} + onChange={setStatsWindow} + custom={customRange} + onCustomChange={setCustomRange} + onBusy={() => setStatsLoading(true)} + t={t} /> - {statsWindow === 'custom' ? ( - { - if (next) setStatsLoading(true) - setCustomRange(next) - }} - ariaLabel={t('statsWindowCustom')} - fromLabel={t('logsTimeFrom')} - toLabel={t('logsTimeTo')} - /> - ) : null}