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/credit-expiry-distribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### English

- Overview now shows a credit-expiry distribution: credits grouped by the calendar day they lapse, one colour per account, plus a soonest-expiry table (numeric columns centred) with the daily burn rate needed to spend each batch before it expires (FEFO). Already-expired packages that still hold remain appear in a dedicated bucket.
- Overview adds a usage breakdown table with token split (prompt / completion / total), prefix-cache hit rate, error count and latency, switchable between account / model / realm dimensions and sortable by total tokens, requests, errors or latency, following the selected window (1h / 24h / 7d / 30d / custom). The 30s / visibility refresh quietly reloads this panel with the traffic chart.
- Overview adds a credit deduction history that sums upstream `usage.credit` per account and per model, with KPI tiles for credits deducted, matched tokens, average credits per 1M tokens, valid samples and cache hit rate; requests without a credit reading are excluded and nothing is back-filled, model rates come from the regional price catalog. Credit SQL shares one nullable expression with the #361 zero-coalesced form.

### 中文

- 概览新增「积分到期分布」:按自然日聚合各账号即将失效的积分,颜色区分账号;并附最近到期提醒表(数字列居中),给出该日到期积分与到期前需要的日均消耗(FEFO)。过期但仍有余额的包单独成桶。
- 概览新增「用量明细」:按维度展示 token 构成(Prompt / Completion / 合计)、前缀缓存命中率、失败数与延迟,可在账号 / 模型 / 域之间切换,并支持按合计 Token、请求数、失败数、平均延迟排序,跟随上方所选时间窗口(1 小时 / 24 小时 / 7 天 / 30 天 / 自定义)。30 秒 / 切回可见时与流量图一并 quiet 刷新。
- 概览新增「积分扣除历史」:按账号与模型汇总上游 `usage.credit`,顶部 KPI 展示扣除积分、匹配 Token、平均积分 / 1M Token、有效样本与缓存命中率;未观测到积分的请求不计入、不做任何回填,模型倍率取自区域价格目录。积分 SQL 与 #361 收敛为可空 / 补零两种包装。
2 changes: 2 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Reading this as: self-hosted ops console for operators, with HeroUI compound pri

禁止出现在控制台、favicon、社交卡、README 图里:`#0485F7`、`#7C3AED`、`#A78BFA`、`#FAF7F2`、`#EDE9FE`、`#000000`、霓虹外发光。

分类序列色(仅数据可视化):概览「积分到期分布」要在一张直方图里同时区分 4 个以上账号,`--success` / `--warning` / `--danger` 是状态色,不能拿来当身份色,所以另建一组固定顺序的 `--chart-1…8`(浅色 / 深色各一套,`oklch`,在 [frontend/src/index.css](../frontend/src/index.css) 声明)。它只用于「需要区分身份」的图表色块,不是第二套品牌色板:按钮、聚焦、选中、状态点仍然走 `--accent` / `--success` / `--warning` / `--danger`,顺序固定,同一账号在同一次会话里保持同一色相。除这组序列色外,图表配色仍走语义 token,不要临时写一次性 hex。

### 选中态(必须同一套)

「当前选中」在全控制台是墨色 accent-soft,不是白底、也不是青色块,更不是 HeroUI 蓝。
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,63 @@ export type RequestStats = {
series: RequestStatsPoint[]
}

export type RequestUsageTotals = {
total_tokens: number
prompt_tokens: number
completion_tokens: number
cache_hit_tokens: number
cache_miss_tokens: number
requests: number
errors: number
avg_latency_ms?: number | null
avg_tokens_per_second?: number | null
credits: number
credit_tokens: number
credit_samples: number
credits_per_1m_tokens?: number | null
success_rate: number
}

export type RequestUsageRow = {
key: string
realm?: string
extra?: string
total_tokens: number
prompt_tokens: number
completion_tokens: number
cache_hit_tokens: number
cache_miss_tokens: number
requests: number
errors: number
avg_latency_ms?: number | null
avg_tokens_per_second?: number | null
}

export type RequestUsageCreditRow = {
key: string
nickname?: string
realm?: string
rate?: number | null
requests: number
credit_samples: number
credits: number
credit_tokens: number
credits_per_1m_tokens?: number | null
cache_hit_tokens: number
cache_miss_tokens: number
}

export type RequestUsage = {
window: RequestStatsWindow
totals: RequestUsageTotals
by_account: RequestUsageRow[]
by_model: RequestUsageRow[]
by_realm: RequestUsageRow[]
credit_by_account: RequestUsageCreditRow[]
credit_by_model: RequestUsageCreditRow[]
series: RequestStatsPoint[]
}

export type RequestLogQuery = {
account?: string
status?: string
Expand Down Expand Up @@ -207,6 +264,15 @@ export function fetchRequestStats(query: { hours?: number; from?: string; to?: s
return api<RequestStats>(`/api/logs/stats${suffix}`)
}

export function fetchRequestUsage(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<RequestUsage>(`/api/logs/usage${suffix}`)
}

export function fetchRequestLogs(query: RequestLogQuery = {}) {
const params = new URLSearchParams()
if (query.account) params.set('account', query.account)
Expand Down
275 changes: 275 additions & 0 deletions frontend/src/components/overview/ExpiryDistribution.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { useMemo, useState } from 'react'
import { Button, Card, Table } from '@heroui/react'
import type { AccountRow } from '@/lib/account'
import { formatQuotaAmount } from '@/lib/account'

// ExpiryDistribution renders a per-account credit-expiry view built from the
// per-package detail that /api/overview already returns (quota.packages[]).
// It answers what the flat account cards cannot:
// 1. 按自然日聚合的「积分到期分布」——每天有多少积分会失效,颜色区分账号;
// 2. 「到期提醒」——每个账号最近一批何时到期、还剩多少、日均要耗多少才能用完
// (FEFO:上游按失效时刻先后优先扣减)。
// Everything is derived on the client from quota.packages; no extra request.

type Translate = (key: string, vars?: Record<string, string | number>) => string

type Props = {
accounts: AccountRow[]
t: Translate
}

const DAY_MS = 24 * 60 * 60 * 1000

// Categorical identity palette — one hue per account, stable by index so the
// same account keeps the same colour across chart, legend and table. Defined
// as --chart-N tokens in index.css so light/dark pick their own lightness.
const SERIES = 8
function seriesColor(index: number) {
return `var(--chart-${(index % SERIES) + 1})`
}

type Segment = { id: string; name: string; color: string; remain: number }
type DayBucket = { day: string; label: string; ts: number; segments: Segment[]; total: number }

type AccountExpiry = {
id: string
name: string
color: string
remain: number
expiredRemain: number // remain sitting on packs whose end day is already past
soonest: number // unix sec of soonest *future* pack with remain>0; 0 = none
soonestRemain: number // credits expiring on the soonest *day* (all packs that day)
daysLeft: number // whole days from today (local midnight); -1 = none
dailyBurn: number
packs: number
}

function localDayStart(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}

function pad(n: number) {
return n < 10 ? `0${n}` : String(n)
}

function dayKey(ts: number) {
const d = new Date(ts)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}

export function ExpiryDistribution({ accounts, t }: Props) {
const [expanded, setExpanded] = useState(false)

const { buckets, rows, todayStart, maxTotal } = useMemo(() => {
const todayStart = localDayStart(new Date())
const colorById = new Map<string, string>()
accounts.forEach((a, i) => colorById.set(a.id, seriesColor(i)))

const byDay = new Map<string, DayBucket>()
const rows: AccountExpiry[] = []

for (const account of accounts) {
const packages = account.quota?.packages ?? []
const color = colorById.get(account.id) || seriesColor(0)
const name = account.name || account.id

// Packages that still hold credits and carry a real expiry date. Already-
// expired packs with remain>0 go into a dedicated "expired" bucket so they
// stay visible instead of looking like a future day on the histogram.
const live = packages.filter((p) => (p.remain ?? 0) > 0 && (p.ends_at ?? 0) > 0)

let soonest = 0
let soonestDayTotal = 0 // same-account credits expiring on the soonest day
let remain = 0
let expiredRemain = 0
for (const p of live) {
const ends = (p.ends_at as number) * 1000
const amount = p.remain ?? 0
remain += amount
const dayTs = localDayStart(new Date(ends))
const expired = dayTs < todayStart
if (expired) expiredRemain += amount
else if (soonest === 0 || ends < soonest) soonest = ends

const key = expired ? '__expired__' : dayKey(dayTs)
let bucket = byDay.get(key)
if (!bucket) {
bucket = expired
? { day: key, label: '!', ts: todayStart - DAY_MS, segments: [], total: 0 }
: (() => {
const d = new Date(dayTs)
return { day: key, label: `${d.getMonth() + 1}/${d.getDate()}`, ts: dayTs, segments: [], total: 0 }
})()
byDay.set(key, bucket)
}
const existing = bucket.segments.find((s) => s.id === account.id)
if (existing) existing.remain += amount
else bucket.segments.push({ id: account.id, name, color, remain: amount })
bucket.total += amount
}

// Credits expiring on the soonest day (sum every pack that shares that
// local day) — matches the "该日到期" bar for this account on the chart.
if (soonest) {
const soonestKey = dayKey(localDayStart(new Date(soonest)))
for (const p of live) {
if (dayKey(localDayStart(new Date((p.ends_at as number) * 1000))) === soonestKey) {
soonestDayTotal += p.remain ?? 0
}
}
}

const daysLeft = soonest
? Math.max(0, Math.round((localDayStart(new Date(soonest)) - todayStart) / DAY_MS))
: -1
rows.push({
id: account.id,
name,
color,
remain,
expiredRemain,
soonest: soonest ? Math.floor(soonest / 1000) : 0,
soonestRemain: soonestDayTotal,
daysLeft,
dailyBurn: soonest ? soonestDayTotal / Math.max(daysLeft, 1) : 0,
packs: live.length,
})
}

rows.sort((a, b) => {
if (a.soonest && b.soonest) return a.soonest - b.soonest
if (a.soonest) return -1
if (b.soonest) return 1
return b.remain - a.remain
})

const buckets = [...byDay.values()].sort((a, b) => a.ts - b.ts)
const maxTotal = buckets.reduce((m, b) => Math.max(m, b.total), 0)
return { buckets, rows, todayStart, maxTotal }
}, [accounts])

if (!rows.some((r) => r.packs > 0)) return null

const shownBuckets = expanded ? buckets : buckets.slice(0, 8)

return (
<Card className="overflow-hidden p-0" data-gsap-reveal>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-separator px-5 py-4">
<div className="min-w-0">
<h3 className="font-semibold">{t('expiryDistTitle')}</h3>
<p className="mt-0.5 text-xs text-muted">{t('expiryDistHint')}</p>
</div>
{buckets.length > 8 ? (
<Button size="sm" variant="ghost" onPress={() => setExpanded((v) => !v)}>
{expanded ? t('expiryDistCollapse') : t('expiryDistExpand', { n: buckets.length })}
</Button>
) : null}
</div>

<div className="space-y-3 p-5">
{/* 按自然日聚合的到期分布:每天一根横条,整条宽度按当日总量相对最大日
缩放(真正的直方图),条内分段 = 账号,段色区分账号。 */}
<div className="space-y-1.5">
{shownBuckets.map((bucket) => {
const isToday = bucket.ts === todayStart
const scale = maxTotal > 0 ? (bucket.total / maxTotal) * 100 : 0
return (
<div key={bucket.day} className="grid grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-2">
<span className={`mono text-caption ${bucket.day === '__expired__' ? 'font-semibold text-danger' : isToday ? 'font-semibold text-foreground' : 'text-muted'}`}>
{bucket.day === '__expired__' ? t('expiryDistExpired') : bucket.label}
</span>
<div
className="h-2.5 w-full rounded-full bg-surface-tertiary"
role="img"
aria-label={`${bucket.day} ${t('expiryDistDayTotal', { n: formatQuotaAmount(bucket.total) })}`}
title={bucket.day}
>
<div className="flex h-full items-stretch overflow-hidden rounded-full" style={{ width: `${scale}%` }}>
{bucket.segments.map((seg) => (
<span
key={seg.id}
style={{ width: `${(seg.remain / bucket.total) * 100}%`, backgroundColor: seg.color }}
title={`${seg.name} · ${formatQuotaAmount(seg.remain)}`}
/>
))}
</div>
</div>
<span className="mono text-caption text-foreground">{formatQuotaAmount(bucket.total)}</span>
</div>
)
})}
</div>

{/* 图例:账号 → 颜色 */}
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pt-1">
{rows
.filter((r) => r.remain > 0)
.map((r) => (
<span key={r.id} className="inline-flex items-center gap-1.5 text-caption text-muted">
<i className="size-2 rounded-[3px]" style={{ backgroundColor: r.color }} />
<span className="max-w-[120px] truncate">{r.name}</span>
</span>
))}
</div>

{/* 到期提醒:最近一批 / 距到期 / 该日到期 / 日均需耗 */}
<Table aria-label={t('expiryDistTitle')}>
<Table.ScrollContainer>
<Table.Content>
<Table.Header>
<Table.Column isRowHeader>{t('expiryDistColAccount')}</Table.Column>
<Table.Column><div className="text-center">{t('expiryDistColSoonest')}</div></Table.Column>
<Table.Column><div className="text-center">{t('expiryDistColDaysLeft')}</div></Table.Column>
<Table.Column><div className="text-center">{t('expiryDistColBatch')}</div></Table.Column>
<Table.Column><div className="text-center">{t('expiryDistColDaily')}</div></Table.Column>
</Table.Header>
<Table.Body>
{rows
.filter((r) => r.packs > 0)
.map((r) => {
const urgent = r.daysLeft >= 0 && r.daysLeft <= 2
const soon = r.daysLeft >= 0 && r.daysLeft <= 7
const soonestDate = r.soonest ? new Date(r.soonest * 1000) : null
return (
<Table.Row key={r.id}>
<Table.Cell>
<span className="inline-flex items-center gap-1.5">
<i className="size-2 shrink-0 rounded-[3px]" style={{ backgroundColor: r.color }} />
<span className="max-w-[150px] truncate text-foreground">{r.name}</span>
</span>
</Table.Cell>
<Table.Cell>
<div className="mono text-center text-muted">
{soonestDate ? `${soonestDate.getMonth() + 1}/${soonestDate.getDate()}` : '—'}
</div>
</Table.Cell>
<Table.Cell>
<div className={`mono text-center ${r.expiredRemain > 0 && r.daysLeft < 0 ? 'font-semibold text-danger' : urgent ? 'font-semibold text-danger' : soon ? 'text-warning' : 'text-muted'}`}>
{r.daysLeft >= 0
? t('expiryDistDays', { n: r.daysLeft })
: r.expiredRemain > 0
? t('expiryDistExpired')
: '—'}
</div>
</Table.Cell>
<Table.Cell>
<div className="mono text-center text-foreground">{formatQuotaAmount(r.soonestRemain)}</div>
</Table.Cell>
<Table.Cell>
<div className="mono text-center text-foreground">
{r.soonest ? formatQuotaAmount(r.dailyBurn) : '—'}
</div>
</Table.Cell>
</Table.Row>
)
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>

<p className="text-caption text-muted">{t('expiryDistFoot')}</p>
</div>
</Card>
)
}
Loading
Loading