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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Show request volume, success rate, latency, tokens, and hourly traffic on Overview, with 1h / 24h / 7d windows from SQLite request history
- Shrink Accounts cards: denser identity row, a compact runtime meter, and a quota fill that animates remaining credits
- Show page skeletons again when the console refresh button is used, instead of leaving stale cards on screen
- Show a list skeleton on Logs while filters, pagination, or refresh are loading, without replacing the filter bar
Expand All @@ -17,6 +18,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### 中文

- 概览页展示请求量、成功率、延迟、token 与按小时流量,时间窗口为 1 小时 / 24 小时 / 7 天,数据来自 SQLite 请求历史
- 账号卡片改为更紧凑的身份行:运行状态用短柱状指示,额度用填充条显示剩余量
- 控制台点刷新时重新显示骨架屏,不再把旧卡片留在页面上
- 日志页筛选、翻页或刷新时,下方列表显示骨架屏,筛选栏保持不动
Expand Down
3 changes: 2 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ Keep the menu short. Login is a gate, not a nav item.
| Route | Nav | Job |
|-------|-----|-----|
| `/login` | no | Console password |
| `/` | Overview | Runtime pulse |
| `/` | Overview | Runtime pulse + request stats |
| `/accounts` | Accounts | Qoder login + pool |
| `/providers` | Models | Catalog + per-model context-window defaults |
| `/access` | Access | Base URL + quick chat |
Expand Down Expand Up @@ -447,6 +447,7 @@ Rules:
- Runtime ring redacts obvious secrets and is lost on restart. Docker compose logs remain the durable operator stream for first-boot API key recovery.
- `/api/logs/*` requires the SQLite API key.
- Request history list accepts `account`, `status`, `stream`, `error_kind`, `model`, `q`, `from`, `to`, `limit`, and `offset`. The console `/logs` page paginates this list and exposes those filters.
- `GET /api/logs/stats` aggregates counts, success rate, latency percentiles, tokens, error mix, and a time series for Overview. Windows are 1h / 24h / 7d; series buckets are 15 minutes, hourly, or daily.
- Runtime snapshot accepts `account` in addition to `level` and `q`.

## Managed update
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,67 @@ export type RuntimeLogList = {
count: number
}

export type RequestStatsWindow = {
from: string
to: string
hours: number
}

export type RequestStatsTotals = {
requests: number
ok: number
error: number
canceled: number
streaming: number
success_rate: number
}

export type RequestStatsLatency = {
avg_ms?: number | null
p50_ms?: number | null
p95_ms?: number | null
ttfb_avg_ms?: number | null
}

export type RequestStatsTokens = {
prompt: number
completion: number
cache_read: number
total: number
}

export type RequestStatsBucket = {
key: string
count: number
}

export type RequestStatsNamed = {
key: string
count: number
ok: number
error: number
latency_avg_ms?: number | null
}

export type RequestStatsPoint = {
at: string
requests: number
ok: number
error: number
}

export type RequestStats = {
window: RequestStatsWindow
totals: RequestStatsTotals
latency: RequestStatsLatency
tokens: RequestStatsTokens
status: RequestStatsBucket[]
errors: RequestStatsBucket[]
models: RequestStatsNamed[]
accounts: RequestStatsNamed[]
series: RequestStatsPoint[]
}

export type RequestLogQuery = {
account?: string
status?: string
Expand All @@ -75,6 +136,15 @@ export type RequestLogQuery = {
offset?: number
}

export function fetchRequestStats(query: { hours?: number; from?: string; to?: string } = {}) {
const params = new URLSearchParams()
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)
const suffix = params.toString() ? `?${params}` : ''
return api<RequestStats>(`/api/logs/stats${suffix}`)
}

export function fetchRequestLogs(query: RequestLogQuery = {}) {
const params = new URLSearchParams()
if (query.account) params.set('account', query.account)
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/components/overview/CountUp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useLayoutEffect, useRef } from 'react'
import gsap from 'gsap'
import { formatCountKind } from '@/lib/format'

export function CountUp({
value,
kind = 'int',
className,
}: {
value: number
kind?: 'int' | 'compact' | 'percent' | 'ms'
className?: string
}) {
const ref = useRef<HTMLSpanElement>(null)

useLayoutEffect(() => {
const el = ref.current
if (!el) return
const state = { value: 0 }
const context = gsap.context(() => {
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: reduce)', () => {
el.textContent = formatCountKind(value, kind)
})
media.add('(prefers-reduced-motion: no-preference)', () => {
gsap.to(state, {
value,
duration: 0.5,
ease: 'power3.out',
onUpdate: () => {
el.textContent = formatCountKind(state.value, kind)
},
})
})
}, el)
return () => context.revert()
}, [kind, value])

return <span ref={ref} className={className}>{formatCountKind(0, kind)}</span>
}
87 changes: 87 additions & 0 deletions frontend/src/components/overview/TrafficChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useLayoutEffect, useRef } from 'react'
import gsap from 'gsap'
import type { RequestStatsPoint } from '@/api/logs'

function hourLabel(value: string, daily: boolean, lang: 'en' | 'zh') {
const date = new Date(value)
if (!Number.isFinite(date.getTime())) return ''
return new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', daily
? { month: 'short', day: 'numeric' }
: { hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
}

export function TrafficChart({
series,
lang,
emptyLabel,
}: {
series: RequestStatsPoint[]
lang: 'en' | 'zh'
emptyLabel: string
}) {
const rootRef = useRef<HTMLDivElement>(null)
const peak = Math.max(1, ...series.map((point) => point.requests))
const first = series[0]
const last = series[series.length - 1]
const span = first && last ? Date.parse(last.at) - Date.parse(first.at) : 0
const daily = span > 48 * 60 * 60 * 1000

useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
const context = gsap.context(() => {
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: reduce)', () => {
gsap.set('[data-bar]', { clearProps: 'transform' })
})
media.add('(prefers-reduced-motion: no-preference)', () => {
const bars = gsap.utils.toArray<HTMLElement>('[data-bar]')
if (!bars.length) return
gsap.fromTo(bars, { scaleY: 0 }, {
scaleY: 1,
duration: 0.42,
ease: 'power2.out',
stagger: { amount: Math.min(0.45, bars.length * 0.012) },
transformOrigin: '50% 100%',
})
})
}, root)
return () => context.revert()
}, [series])

if (!series.length || series.every((point) => point.requests === 0)) {
return (
<div className="grid min-h-36 place-items-center rounded-lg border border-dashed border-[var(--app-line-strong)] px-4 text-sm text-[var(--app-faint)]">
{emptyLabel}
</div>
)
}

return (
<div ref={rootRef} className="min-w-0">
<div className="flex h-36 items-end gap-px">
{series.map((point) => {
const height = Math.max(point.requests ? 8 : 2, Math.round((point.requests / peak) * 100))
const failed = point.requests > 0 && point.error / point.requests >= 0.5
return (
<div
key={point.at}
className="group relative flex min-w-0 flex-1 items-end"
title={`${hourLabel(point.at, daily, lang)} · ${point.requests}`}
>
<span
data-bar
className={`block w-full origin-bottom rounded-[2px] ${failed ? 'bg-[var(--app-danger)]' : 'bg-[var(--app-ok)]'} ${point.requests ? 'opacity-90' : 'bg-[var(--app-line-strong)] opacity-50'}`}
style={{ height: `${height}%` }}
/>
</div>
)
})}
</div>
<div className="mt-2 flex items-center justify-between text-[10px] text-[var(--app-faint)]">
<span className="mono">{first ? hourLabel(first.at, daily, lang) : ''}</span>
<span className="mono">{last ? hourLabel(last.at, daily, lang) : ''}</span>
</div>
</div>
)
}
9 changes: 5 additions & 4 deletions frontend/src/components/ui/PageSkeletons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ function SkeletonBlock({ className }: { className: string }) {

export function OverviewPageSkeleton() {
return (
<div className="space-y-7" aria-label="Loading overview">
<section className="flex items-end justify-between gap-4 border-b border-[var(--app-line)] pb-4"><div className="space-y-3"><SkeletonBlock className="h-3 w-36" /><SkeletonBlock className="h-8 w-48" /><SkeletonBlock className="h-4 w-80 max-w-[70vw]" /></div><div className="hidden space-y-2 sm:block"><SkeletonBlock className="ml-auto h-3 w-28" /><SkeletonBlock className="ml-auto h-4 w-20" /></div></section>
<section className="grid overflow-hidden rounded-lg border border-[var(--app-line)] bg-[var(--app-surface)] sm:grid-cols-2 xl:grid-cols-4">{Array.from({ length: 4 }, (_, index) => <div key={index} className="min-h-36 space-y-5 border-[var(--app-line)] p-5 first:border-0 sm:border-l sm:first:border-0"><SkeletonBlock className="h-4 w-24" /><SkeletonBlock className="h-8 w-28" /><SkeletonBlock className="h-3 w-36" /></div>)}</section>
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(380px,.85fr)]"><div className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-32" /><SkeletonBlock className="mt-2 h-3 w-48" /><div className="mt-6 space-y-4">{Array.from({ length: 5 }, (_, index) => <SkeletonBlock key={index} className="h-10 w-full" />)}</div></div><div className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-24" /><SkeletonBlock className="mt-2 h-3 w-40" /><div className="mt-6 space-y-4">{Array.from({ length: 4 }, (_, index) => <SkeletonBlock key={index} className="h-12 w-full" />)}</div></div></section>
<div className="space-y-6" aria-label="Loading overview">
<section className="flex items-end justify-between gap-4 border-b border-[var(--app-line)] pb-4"><div className="space-y-3"><SkeletonBlock className="h-8 w-40" /><SkeletonBlock className="h-4 w-80 max-w-[70vw]" /></div><div className="hidden space-y-2 sm:block"><SkeletonBlock className="ml-auto h-8 w-40" /><SkeletonBlock className="ml-auto h-4 w-20" /></div></section>
<section className="grid overflow-hidden rounded-lg border border-[var(--app-line)] bg-[var(--app-surface)] sm:grid-cols-2 xl:grid-cols-4">{Array.from({ length: 4 }, (_, index) => <div key={index} className="min-h-32 space-y-5 border-[var(--app-line)] p-5 first:border-0 sm:border-l sm:first:border-0"><SkeletonBlock className="h-4 w-24" /><SkeletonBlock className="h-8 w-28" /><SkeletonBlock className="h-3 w-36" /></div>)}</section>
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,.75fr)]"><div className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-24" /><SkeletonBlock className="mt-2 h-3 w-48" /><SkeletonBlock className="mt-6 h-36 w-full" /><div className="mt-5 grid grid-cols-4 gap-3">{Array.from({ length: 4 }, (_, index) => <SkeletonBlock key={index} className="h-10 w-full" />)}</div></div><div className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-28" /><SkeletonBlock className="mt-2 h-3 w-40" /><div className="mt-6 space-y-4">{Array.from({ length: 5 }, (_, index) => <SkeletonBlock key={index} className="h-8 w-full" />)}</div></div></section>
<section className="grid gap-5 xl:grid-cols-3">{Array.from({ length: 3 }, (_, index) => <div key={index} className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-28" /><div className="mt-6 space-y-4">{Array.from({ length: 4 }, (_, item) => <SkeletonBlock key={item} className="h-8 w-full" />)}</div></div>)}</section>
</div>
)
}
Expand Down
64 changes: 60 additions & 4 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const messages: Record<Lang, Dict> = {
checkingSession: 'Checking session…',
refreshing: 'Refreshing',
pages: {
home: { title: 'Overview', desc: 'Inspect local proxy health and ready endpoints.' },
home: { title: 'Overview', desc: 'Runtime health, request volume, and ready endpoints.' },
auth: { title: 'Auth', desc: 'Browser device-flow login, PAT login, and session rewarm.' },
providers: { title: 'Models', desc: 'Models currently exposed by signed-in accounts.' },
access: { title: 'Access', desc: 'Copy Base URL and run a quick Chat Completions check.' },
Expand Down Expand Up @@ -164,7 +164,35 @@ export const messages: Record<Lang, Dict> = {

homeEyebrow: 'Runtime',
homeDisplay: 'Gateway pulse',
homeLead: 'Dense status without boxed metric cards. Quiet chrome, cream actions, green only for health.',
homeLead: 'Runtime health plus request volume, latency, and tokens from this host.',
metricRequests: 'Requests',
metricSuccess: 'Success',
metricLatency: 'p95',
metricTokens: 'Tokens',
statsWindow1h: '1h',
statsWindow24h: '24h',
statsWindow7d: '7d',
statsWindowHint: '{window} window',
statsTraffic: 'Traffic',
statsTrafficHint: 'Completed chat requests in the selected window',
statsEmptyTraffic: 'No requests in this window yet.',
statsLatencyP50: 'p50',
statsLatencyP95: 'p95',
statsLatencyAvg: 'avg',
statsTTFB: 'TTFB',
statsPool: 'Account pool',
statsPoolHint: 'Ready workers and remaining quota',
statsCooling: 'cooling',
statsTopModels: 'Top models',
statsTopAccounts: 'Top accounts',
statsErrorMix: 'Error mix',
statsErrorMixHint: 'Classified failures in this window',
statsNoModels: 'No model traffic yet.',
statsNoAccounts: 'No account traffic yet.',
statsNoErrors: 'No classified errors.',
statsUnknown: 'unknown',
statsUnassigned: 'unassigned',
failedStats: 'Failed to load stats: {msg}',
homeNote: 'Signed into the local console. Account logins live on the Accounts page.',
metricRuntime: 'Runtime',
metricAccounts: 'Accounts',
Expand Down Expand Up @@ -469,7 +497,7 @@ export const messages: Record<Lang, Dict> = {
checkingSession: '正在检查登录态…',
refreshing: '刷新中',
pages: {
home: { title: '概览', desc: '查看本地代理运行状态与可用端点。' },
home: { title: '概览', desc: '查看运行状态、请求量与可用端点。' },
auth: { title: '授权', desc: '浏览器 device-flow 登录、PAT 登录,以及会话 rewarm。' },
providers: { title: '模型', desc: '当前已登录账号可用的模型列表。' },
access: { title: '接入', desc: '复制 Base URL,并快速验证 Chat Completions。' },
Expand Down Expand Up @@ -537,7 +565,35 @@ export const messages: Record<Lang, Dict> = {

homeEyebrow: '运行时',
homeDisplay: '网关状态',
homeLead: '信息密集但不堆卡片。主操作奶油色,绿色只表示健康状态。',
homeLead: '运行状态之外,还能看到本机请求量、延迟和 token。',
metricRequests: '请求',
metricSuccess: '成功率',
metricLatency: 'p95',
metricTokens: 'Tokens',
statsWindow1h: '1小时',
statsWindow24h: '24小时',
statsWindow7d: '7天',
statsWindowHint: '{window}窗口',
statsTraffic: '流量',
statsTrafficHint: '所选时间窗口内的已完成聊天请求',
statsEmptyTraffic: '这个时间窗口还没有请求。',
statsLatencyP50: 'p50',
statsLatencyP95: 'p95',
statsLatencyAvg: '平均',
statsTTFB: 'TTFB',
statsPool: '账号池',
statsPoolHint: '就绪 worker 与剩余额度',
statsCooling: '冷却中',
statsTopModels: '热门模型',
statsTopAccounts: '热门账号',
statsErrorMix: '错误构成',
statsErrorMixHint: '该窗口内已分类的失败',
statsNoModels: '还没有模型流量。',
statsNoAccounts: '还没有账号流量。',
statsNoErrors: '没有分类错误。',
statsUnknown: '未知',
statsUnassigned: '未分配',
failedStats: '加载统计失败:{msg}',
homeNote: '已登录本机控制台。账号登录在「账号」页。',
metricRuntime: '运行状态',
metricAccounts: '账号',
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/lib/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function formatCompact(value: number) {
if (!Number.isFinite(value)) return '—'
const abs = Math.abs(value)
if (abs >= 1_000_000) return `${trimFixed(value / 1_000_000)}M`
if (abs >= 1000) return `${trimFixed(value / 1000)}k`
return String(Math.round(value))
}

export function formatLatency(ms: number | null | undefined) {
if (ms == null || !Number.isFinite(ms)) return '—'
if (ms >= 1000) return `${trimFixed(ms / 1000)}s`
return `${Math.round(ms)}ms`
}

export function formatPercent(rate: number | null | undefined) {
if (rate == null || !Number.isFinite(rate)) return '—'
const pct = rate * 100
return `${pct >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%`
}

export function formatCountKind(value: number, kind: 'int' | 'compact' | 'percent' | 'ms') {
if (kind === 'compact') return formatCompact(value)
if (kind === 'percent') return formatPercent(value)
if (kind === 'ms') return formatLatency(value)
return String(Math.round(value))
}

function trimFixed(value: number) {
const digits = Math.abs(value) >= 10 ? 0 : 1
return Number(value.toFixed(digits)).toString()
}
Loading
Loading