Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog/unreleased/usage-metrics.md
Original file line number Diff line number Diff line change
@@ -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` 筛选,概览的供应商排行也可下钻到日志。
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -31,6 +32,7 @@ export default function App() {
<Route path="/access" element={<AccessPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/checkins" element={<Navigate to="/accounts" replace />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/keys" element={<KeysPage />} />
<Route path="/system" element={<SystemPage />} />
Expand Down
26 changes: 25 additions & 1 deletion frontend/src/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export type RequestStatsTotals = {
canceled: number
streaming: number
success_rate: number
credits?: number
}

export type RequestStatsLatency = {
Expand All @@ -125,6 +126,7 @@ export type RequestStatsTokens = {
prompt: number
completion: number
cache_read: number
cache_write?: number
total: number
}

Expand All @@ -139,15 +141,28 @@ 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 = {
at: string
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
Expand All @@ -167,6 +182,7 @@ export type RequestLogQuery = {
stream?: boolean
error_kind?: string
model?: string
provider?: string
id?: string
q?: string
from?: string
Expand All @@ -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)
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const routeKey: Record<string, string> = {
'/providers': 'providers',
'/access': 'access',
'/accounts': 'accounts',
'/usage': 'usage',
'/logs': 'logs',
'/keys': 'keys',
'/system': 'system',
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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' },
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/components/overview/StatsWindowControls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<FilterToggle
value={value}
onChange={(next) => {
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' ? (
<TimeRangePicker
className="w-[min(100%,24rem)]"
value={custom}
onChange={(next) => {
if (next) onBusy?.()
onCustomChange(next)
}}
ariaLabel={t('statsWindowCustom')}
fromLabel={t('logsTimeFrom')}
toLabel={t('logsTimeTo')}
/>
) : null}
</>
)
}
Loading
Loading