feat(usage): rebuild token trend chart and fill usage page height

This commit is contained in:
lzt404 committed 2026-09-15 02:39:03 +08:00
1 parent 67484ac423
commit accb93c75c
8 files changed
+1622 -119

No files matched your search

+63 -12
View File
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{
collections::{HashMap, HashSet},
collections::{BTreeMap, HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{LazyLock, Mutex},
@@ -401,6 +401,15 @@ struct UsageCostGroup {
total_tokens: u64,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsageTimelineModel {
key: String,
label: String,
tokens: u64,
requests: u64,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsageTimelinePoint {
@@ -410,6 +419,7 @@ struct UsageTimelinePoint {
failure: u64,
canceled: u64,
tokens: u64,
models: Vec<UsageTimelineModel>,
}
#[derive(Default, Serialize)]
@@ -2640,34 +2650,71 @@ fn load_usage_overview(
r#"
SELECT
local_hour,
COALESCE(NULLIF(TRIM(model), ''), 'unknown'),
COUNT(*),
COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM usage_events{}
GROUP BY local_hour
ORDER BY local_hour ASC
GROUP BY local_hour, 2
ORDER BY local_hour ASC, 2 ASC
"#,
filter.clause
);
let mut statement = connection
.prepare(&timeline_sql)
.map_err(|error| format!("准备 SQLite 使用趋势查询失败: {error}"))?;
let timeline = statement
let timeline_rows = statement
.query_map(params_from_iter(filter.params.iter()), |row| {
Ok(UsageTimelinePoint {
hour: row.get(0)?,
requests: from_sql_i64(row.get(1)?),
success: from_sql_i64(row.get(2)?),
failure: from_sql_i64(row.get(3)?),
canceled: from_sql_i64(row.get(4)?),
tokens: from_sql_i64(row.get(5)?),
})
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
from_sql_i64(row.get(2)?),
from_sql_i64(row.get(3)?),
from_sql_i64(row.get(4)?),
from_sql_i64(row.get(5)?),
from_sql_i64(row.get(6)?),
))
})
.map_err(|error| format!("查询 SQLite 使用趋势失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取 SQLite 使用趋势失败: {error}"))?;
let mut grouped = BTreeMap::<String, UsageTimelinePoint>::new();
for (hour, model, requests, success, failure, canceled, tokens) in timeline_rows {
let point = grouped.entry(hour.clone()).or_insert_with(|| UsageTimelinePoint {
hour,
..UsageTimelinePoint::default()
});
point.requests = point.requests.saturating_add(requests);
point.success = point.success.saturating_add(success);
point.failure = point.failure.saturating_add(failure);
point.canceled = point.canceled.saturating_add(canceled);
point.tokens = point.tokens.saturating_add(tokens);
if let Some(existing) = point.models.iter_mut().find(|item| item.key == model) {
existing.requests = existing.requests.saturating_add(requests);
existing.tokens = existing.tokens.saturating_add(tokens);
} else {
point.models.push(UsageTimelineModel {
key: model.clone(),
label: model,
tokens,
requests,
});
}
}
let timeline = grouped
.into_values()
.map(|mut point| {
point.models.sort_by(|left, right| {
right
.tokens
.cmp(&left.tokens)
.then_with(|| left.key.cmp(&right.key))
});
point
})
.collect::<Vec<_>>();
let mut overview = UsageOverview {
total_requests: from_sql_i64(summary.0),
@@ -5483,6 +5530,10 @@ mod tests {
assert_eq!(overview.cache_hit_rate, 0.2);
assert!((overview.estimated_cost - 0.0002564).abs() < 0.0000001);
assert_eq!(overview.priced_requests, 1);
assert_eq!(overview.timeline.len(), 1);
assert_eq!(overview.timeline[0].models.len(), 1);
assert_eq!(overview.timeline[0].models[0].key, "gpt-5.6-terra");
assert!(overview.timeline[0].tokens > 0);
assert_eq!(analysis.models[0].key, "gpt-5.6-terra");
assert_eq!(events.total, 1);
assert_eq!(events.items[0].id, "request-2");
+11 -3
View File
@@ -459,8 +459,7 @@ export const jaOverrides = {
'usage.dataManagement.repaired': '修復件数',
'usage.dataManagement.backup': 'バックアップ',
'usage.dataManagement.success': '履歴データを処理しました。{repaired} 件を修復し、誤った記録を {deleted} 件削除しました。',
'usage.trend.title': 'リクエストと Token の推移',
'usage.trend.description': 'システムのローカル時刻ごとに集計',
'usage.trend.title': 'Token の推移',
'usage.token.title': 'Token の内訳',
'usage.token.description': 'キャッシュ、推論、生成の消費量',
'usage.token.input': '入力',
@@ -468,7 +467,16 @@ export const jaOverrides = {
'usage.token.reasoning': '推論',
'usage.token.cacheRead': 'キャッシュ読み取り',
'usage.token.cacheCreation': 'キャッシュ作成',
'usage.trend.aria': 'リクエスト推移',
'usage.trend.aria': 'Token の推移図。マウスまたは矢印キーで各時間帯とモデルの内訳を確認できます。',
'usage.trend.bucket.hour': '1 時間ごとに表示',
'usage.trend.bucket.3h': '3 時間ごとに集計',
'usage.trend.bucket.day': '日ごとに集計',
'usage.trend.bucket.week': '週ごとに集計',
'usage.trend.bucket.month': '月ごとに集計',
'usage.trend.bucket.year': '年ごとに集計',
'usage.trend.other': 'その他',
'usage.trend.star': '主星',
'usage.trend.tooltip.tokens': 'Token',
'usage.analysis.models': 'モデル使用量',
'usage.analysis.sources': 'リクエスト元',
'usage.analysis.keys': '認証キー',
+11 -3
View File
@@ -447,8 +447,7 @@ export const en: Record<MessageKey, string> = {
'usage.dataManagement.deleted': 'Invalid records deleted',
'usage.dataManagement.backup': 'Backup file',
'usage.dataManagement.success': 'Historical data processed: {repaired} repaired and {deleted} invalid records deleted.',
'usage.trend.title': 'Request and Token Trend',
'usage.trend.description': 'Grouped by local system hour',
'usage.trend.title': 'Token Trend',
'usage.token.title': 'Token Breakdown',
'usage.token.description': 'Cache, reasoning, and generation usage',
'usage.token.input': 'Input',
@@ -456,7 +455,16 @@ export const en: Record<MessageKey, string> = {
'usage.token.reasoning': 'Reasoning',
'usage.token.cacheRead': 'Cache Read',
'usage.token.cacheCreation': 'Cache Creation',
'usage.trend.aria': 'Request trend',
'usage.trend.aria': 'Token trend chart. Hover or use arrow keys to inspect each bucket and model.',
'usage.trend.bucket.hour': 'Shown by hour',
'usage.trend.bucket.3h': 'Grouped every 3 hours',
'usage.trend.bucket.day': 'Grouped by day',
'usage.trend.bucket.week': 'Grouped by week',
'usage.trend.bucket.month': 'Grouped by month',
'usage.trend.bucket.year': 'Grouped by year',
'usage.trend.other': 'Other',
'usage.trend.star': 'Lead',
'usage.trend.tooltip.tokens': 'Tokens',
'usage.analysis.models': 'Model Usage',
'usage.analysis.sources': 'Request Sources',
'usage.analysis.keys': 'Authentication Keys',
+11 -3
View File
@@ -446,8 +446,7 @@ export const zhCN = {
'usage.dataManagement.deleted': '删除误记录',
'usage.dataManagement.backup': '备份文件',
'usage.dataManagement.success': '历史数据处理完成:修复 {repaired} 条,删除 {deleted} 条误记录。',
'usage.trend.title': '请求与 Token 趋势',
'usage.trend.description': '按系统本地小时聚合',
'usage.trend.title': 'Token 趋势',
'usage.token.title': 'Token 构成',
'usage.token.description': '缓存、思考与生成消耗',
'usage.token.input': '输入',
@@ -455,7 +454,16 @@ export const zhCN = {
'usage.token.reasoning': '思考',
'usage.token.cacheRead': '缓存读取',
'usage.token.cacheCreation': '缓存创建',
'usage.trend.aria': '请求趋势',
'usage.trend.aria': 'Token 趋势图,可悬停或用方向键查看各时段和模型明细',
'usage.trend.bucket.hour': '按小时展示',
'usage.trend.bucket.3h': '按 3 小时聚合',
'usage.trend.bucket.day': '按天聚合',
'usage.trend.bucket.week': '按周聚合',
'usage.trend.bucket.month': '按月聚合',
'usage.trend.bucket.year': '按年聚合',
'usage.trend.other': '其他',
'usage.trend.star': '主星',
'usage.trend.tooltip.tokens': 'Token',
'usage.analysis.models': '模型使用',
'usage.analysis.sources': '请求来源',
'usage.analysis.keys': '鉴权密钥',
+408 -57
View File
@@ -1,5 +1,5 @@
import { useConfirmation } from '../components/ConfirmationDialog';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
@@ -31,6 +31,18 @@ import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import type { MessageKey } from '../i18n/resources';
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
import { formatUsageNumber } from '../services/usageNumber';
import {
OTHER_TREND_MODEL_KEY,
buildUsageTrendSeries,
formatTrendAxisLabel,
formatTrendRangeLabel,
niceCeiling,
selectTrendAxisLabels,
smoothAreaPath,
smoothLinePath,
stackModelTokens,
type UsageTimelinePoint,
} from '../services/usageTrend';
import { createRefreshScheduler } from '../services/refreshScheduler';
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing' | 'data-management';
@@ -43,14 +55,7 @@ type CollectorStatus = {
totalRecords: number;
};
type TimelinePoint = {
hour: string;
requests: number;
success: number;
failure: number;
canceled: number;
tokens: number;
};
type TimelinePoint = UsageTimelinePoint;
type UsageOverview = {
totalRequests: number;
@@ -288,6 +293,7 @@ export function UsageRecordsPage() {
const [pageSize, setPageSize] = useState(50);
const [status, setStatus] = useState<CollectorStatus | null>(null);
const [overview, setOverview] = useState<UsageOverview | null>(null);
const [overviewRange, setOverviewRange] = useState<Pick<UsageQuery, 'start' | 'end'>>({});
const [analysis, setAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
const [optionsAnalysis, setOptionsAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
const [events, setEvents] = useState<UsageEventPage | null>(null);
@@ -346,6 +352,7 @@ export function UsageRecordsPage() {
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setOverview(nextOverview);
setOverviewRange(timeQuery);
} else if (activeTab === 'analysis') {
const [nextStatus, nextOptions, nextOverview, nextAnalysis] = await Promise.all([
statusRequest,
@@ -359,6 +366,7 @@ export function UsageRecordsPage() {
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setOverview(nextOverview);
setOverviewRange(timeQuery);
setAnalysis(nextAnalysis);
} else if (activeTab === 'events') {
const [nextStatus, nextOptions, nextEvents] = await Promise.all([
@@ -702,7 +710,7 @@ export function UsageRecordsPage() {
</div>
) : null}
{activeTab === 'overview' && overview ? <OverviewView overview={overview} /> : null}
{activeTab === 'overview' && overview ? <OverviewView overview={overview} range={overviewRange} /> : null}
{activeTab === 'analysis' ? <AnalysisView analysis={analysis} overview={overview} /> : null}
{activeTab === 'events' && events ? (
<EventsView
@@ -790,7 +798,7 @@ function UsageDataManagementView() {
);
}
function OverviewView({ overview }: { overview: UsageOverview }) {
function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pick<UsageQuery, 'start' | 'end'> }) {
const { t } = useI18n();
const cards = [
{
@@ -895,10 +903,9 @@ function OverviewView({ overview }: { overview: UsageOverview }) {
<div className="usage-section-heading">
<div>
<strong>{t('usage.trend.title')}</strong>
<span>{t('usage.trend.description')}</span>
</div>
</div>
{overview.timeline.length ? <UsageTrend points={overview.timeline} /> : <UsageEmpty />}
{overview.timeline.length ? <UsageTrend points={overview.timeline} range={range} /> : <UsageEmpty />}
</section>
<section className="panel usage-health-panel">
<div className="usage-section-heading">
@@ -944,57 +951,401 @@ function OverviewView({ overview }: { overview: UsageOverview }) {
);
}
function UsageTrend({ points }: { points: TimelinePoint[] }) {
const { t } = useI18n();
const recent = points.slice(-48);
const max = Math.max(...recent.map((point) => point.requests), 1);
const totalTokens = recent.reduce((sum, point) => sum + point.tokens, 0);
const totalReqs = recent.reduce((sum, point) => sum + point.requests, 0);
function UsageTrend({
points,
range,
}: {
points: TimelinePoint[];
range?: Pick<UsageQuery, 'start' | 'end'>;
}) {
const { t, locale } = useI18n();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [hiddenModels, setHiddenModels] = useState<string[]>([]);
const pointsCoords = recent.map((point, index) => {
const x = recent.length <= 1 ? 50 : (index * 100) / (recent.length - 1);
const y = 28 - (point.requests * 24) / max;
return { x, y, point };
});
const series = useMemo(
() => buildUsageTrendSeries(points, range),
[points, range?.start, range?.end],
);
const polyline = pointsCoords.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
const areaPath =
pointsCoords.length > 0
? `M 0,32 L ${pointsCoords[0].x.toFixed(1)},${pointsCoords[0].y.toFixed(1)} ` +
pointsCoords.map((p) => `L ${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ') +
` L 100,32 Z`
: '';
const hiddenKeys = useMemo(() => new Set(hiddenModels), [hiddenModels]);
const visibleModels = useMemo(
() => series.models.filter((model) => !hiddenKeys.has(model.key)),
[hiddenKeys, series.models],
);
useEffect(() => {
setHoveredIndex(null);
setHiddenModels((current) => current.filter((key) => series.models.some((model) => model.key === key)));
}, [series.bucket, series.models, series.points[0]?.hour, series.points[series.points.length - 1]?.hour]);
const count = series.points.length;
const chart = useMemo(() => {
if (count === 0) {
return {
maxTokens: 1,
stacked: [],
areaLayers: [],
totalLinePath: '',
labelIndexes: [],
compactSameDay: false,
baseY: 146,
PT: 8,
UH: 138,
};
}
const stacked = series.points.map((point) => stackModelTokens(point, series.models, hiddenKeys));
const maxTokens = niceCeiling(Math.max(1, ...stacked.map((layers) => layers[layers.length - 1]?.y1 ?? 0)));
const VIEWBOX_W = 1000;
const PT = 8;
const PB = 8;
const UH = 154 - PT - PB;
const baseY = PT + UH;
const calcY = (val: number) => (maxTokens > 0 ? baseY - (val / maxTokens) * UH : baseY);
const calcX = (idx: number) => (count <= 1 ? VIEWBOX_W / 2 : (idx / (count - 1)) * VIEWBOX_W);
const areaLayers = visibleModels.map((model) => {
let topPoints: { x: number; y: number }[];
let bottomPoints: { x: number; y: number }[];
if (count <= 1) {
const y1 = calcY(stacked[0]?.find((l) => l.key === model.key)?.y1 ?? 0);
const y0 = calcY(stacked[0]?.find((l) => l.key === model.key)?.y0 ?? 0);
topPoints = [{ x: 0, y: y1 }, { x: VIEWBOX_W, y: y1 }];
bottomPoints = [{ x: 0, y: y0 }, { x: VIEWBOX_W, y: y0 }];
} else {
topPoints = series.points.map((_, idx) => ({
x: calcX(idx),
y: calcY(stacked[idx]?.find((l) => l.key === model.key)?.y1 ?? 0),
}));
bottomPoints = series.points.map((_, idx) => ({
x: calcX(idx),
y: calcY(stacked[idx]?.find((l) => l.key === model.key)?.y0 ?? 0),
}));
}
const areaPath = smoothAreaPath(topPoints, bottomPoints);
const linePath = smoothLinePath(topPoints);
const hasTokens = stacked.some((layers) => (layers.find((l) => l.key === model.key)?.tokens ?? 0) > 0);
return {
model,
areaPath,
linePath,
hasTokens,
};
});
let totalPoints: { x: number; y: number }[];
if (count <= 1) {
const topY = calcY(stacked[0]?.[stacked[0]?.length - 1]?.y1 ?? 0);
totalPoints = [{ x: 0, y: topY }, { x: VIEWBOX_W, y: topY }];
} else {
totalPoints = series.points.map((_, idx) => ({
x: calcX(idx),
y: calcY(stacked[idx]?.[stacked[idx]?.length - 1]?.y1 ?? 0),
}));
}
const totalLinePath = smoothLinePath(totalPoints);
const labelIndexes = selectTrendAxisLabels(count, count > 36 ? 5 : 4);
const compactSameDay =
count > 1 &&
series.points[0].start.toDateString() === series.points[count - 1].start.toDateString();
return {
maxTokens,
stacked,
areaLayers,
totalLinePath,
labelIndexes,
compactSameDay,
baseY,
PT,
UH,
};
}, [count, hiddenKeys, series, visibleModels]);
if (count === 0) {
return <UsageEmpty />;
}
const modelLabel = (key: string, fallback: string) =>
key === OTHER_TREND_MODEL_KEY ? t('usage.trend.other') : fallback;
const bucketLabel = t(`usage.trend.bucket.${series.bucket}` as MessageKey);
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width <= 0 || count === 0) return;
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const idx = Math.round(ratio * (count - 1));
setHoveredIndex(idx);
};
const handlePointerLeave = () => {
setHoveredIndex(null);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (count === 0) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
setHoveredIndex((prev) => (prev == null || prev <= 0 ? count - 1 : prev - 1));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
setHoveredIndex((prev) => (prev == null || prev >= count - 1 ? 0 : prev + 1));
} else if (e.key === 'Home') {
e.preventDefault();
setHoveredIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
setHoveredIndex(count - 1);
} else if (e.key === 'Escape') {
setHoveredIndex(null);
}
};
const active = hoveredIndex != null && hoveredIndex >= 0 && hoveredIndex < count ? series.points[hoveredIndex] : null;
const activeStacked = hoveredIndex != null && chart.stacked[hoveredIndex] ? chart.stacked[hoveredIndex] : [];
const activePercent = count <= 1 ? 50 : ((hoveredIndex ?? 0) / (count - 1)) * 100;
const activeViewboxX = count <= 1 ? 500 : ((hoveredIndex ?? 0) / (count - 1)) * 1000;
const activeLayers = [...activeStacked]
.filter((l) => l.tokens > 0)
.sort((a, b) => b.tokens - a.tokens);
const activeTotal = activeStacked[activeStacked.length - 1]?.y1 ?? 0;
const srText = active
? `${formatTrendRangeLabel(active, locale, series.bucket)}: ${compactNumber(activeTotal)} ${t('usage.unit.tokens')}`
: '';
return (
<div className="usage-trend-wrapper">
<div className="usage-trend-header-meta">
<span className="usage-trend-chip">
<strong>{compactNumber(totalReqs)}</strong> {t('usage.unit.requests')}
</span>
<span className="usage-trend-chip">
<strong>{compactNumber(totalTokens)}</strong> {t('usage.unit.tokens')}
</span>
</div>
<div className="usage-trend">
<svg viewBox="0 0 100 32" preserveAspectRatio="none" aria-label={t('usage.trend.aria')}>
<defs>
<linearGradient id="usageTrendGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--theme-3f6f98)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--theme-3f6f98)" stopOpacity="0.02" />
</linearGradient>
</defs>
<line x1="0" y1="8" x2="100" y2="8" stroke="var(--theme-eeeae2)" strokeDasharray="2,2" vectorEffect="non-scaling-stroke" />
<line x1="0" y1="18" x2="100" y2="18" stroke="var(--theme-eeeae2)" strokeDasharray="2,2" vectorEffect="non-scaling-stroke" />
<line x1="0" y1="28" x2="100" y2="28" stroke="var(--theme-eeeae2)" strokeDasharray="2,2" vectorEffect="non-scaling-stroke" />
{areaPath ? <path d={areaPath} fill="url(#usageTrendGrad)" /> : null}
<polyline points={polyline} fill="none" vectorEffect="non-scaling-stroke" />
</svg>
<div className="usage-trend-labels">
<span>{recent[0]?.hour ?? ''}</span>
{recent.length > 2 ? <span>{recent[Math.floor(recent.length / 2)]?.hour ?? ''}</span> : null}
<span>{recent[recent.length - 1]?.hour ?? ''}</span>
<div className="usage-trend-toolbar">
<div className="usage-trend-legend" role="group" aria-label={t('usage.trend.aria')}>
{series.models.map((model) => {
const isHidden = hiddenKeys.has(model.key);
return (
<button
type="button"
key={model.key}
className={`usage-trend-legend-item${isHidden ? ' is-hidden' : ''}${model.star ? ' is-star' : ''}`}
aria-pressed={!isHidden}
onClick={() =>
setHiddenModels((current) =>
current.includes(model.key)
? current.filter((key) => key !== model.key)
: [...current, model.key],
)
}
>
<span className="usage-trend-swatch" style={{ background: model.color }} />
<span>{modelLabel(model.key, model.label)}</span>
{model.star ? <em>{t('usage.trend.star')}</em> : null}
</button>
);
})}
</div>
<div className="usage-trend-header-meta">
<span className="usage-trend-chip">
<strong>{compactNumber(series.totals.tokens)}</strong> {t('usage.unit.tokens')}
</span>
<span className="usage-trend-chip">{bucketLabel}</span>
</div>
</div>
<div className="usage-trend-chart">
<div className="usage-trend-y-axis" aria-hidden="true">
<span>{compactNumber(chart.maxTokens)}</span>
<span>{compactNumber(Math.round(chart.maxTokens / 2))}</span>
<span>0</span>
</div>
<div
className="usage-trend-plot"
tabIndex={0}
role="region"
aria-label={t('usage.trend.aria')}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onKeyDown={handleKeyDown}
>
<svg
viewBox="0 0 1000 154"
preserveAspectRatio="none"
className="usage-trend-svg"
>
<defs>
{visibleModels.map((model) => (
<linearGradient
key={`grad-${model.key}`}
id={`trend-grad-${model.key.replace(/[^a-zA-Z0-9_-]+/g, '-')}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={model.color}
stopOpacity={model.star ? 0.38 : 0.28}
/>
<stop
offset="100%"
stopColor={model.color}
stopOpacity={model.star ? 0.14 : 0.08}
/>
</linearGradient>
))}
</defs>
<line
x1="0"
y1={chart.PT}
x2="1000"
y2={chart.PT}
className="usage-trend-grid"
/>
<line
x1="0"
y1={chart.PT + chart.UH / 2}
x2="1000"
y2={chart.PT + chart.UH / 2}
className="usage-trend-grid"
/>
<line
x1="0"
y1={chart.baseY}
x2="1000"
y2={chart.baseY}
className="usage-trend-baseline"
/>
{chart.areaLayers.map((layer) => {
if (!layer.hasTokens || !layer.areaPath) return null;
return (
<g key={layer.model.key} className={`usage-trend-layer${layer.model.star ? ' is-star' : ''}`}>
<path
d={layer.areaPath}
fill={`url(#trend-grad-${layer.model.key.replace(/[^a-zA-Z0-9_-]+/g, '-')})`}
className="usage-trend-area"
/>
<path
d={layer.linePath}
fill="none"
stroke={layer.model.color}
strokeWidth={layer.model.star ? 1.8 : 1.4}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="usage-trend-line"
/>
</g>
);
})}
{chart.totalLinePath ? (
<path
d={chart.totalLinePath}
fill="none"
stroke="var(--theme-3f6f98)"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="usage-trend-total-line"
/>
) : null}
{active && hoveredIndex != null ? (
<g className="usage-trend-active-mark">
<line
x1={activeViewboxX}
y1={chart.PT}
x2={activeViewboxX}
y2={chart.baseY}
className="usage-trend-cursor-line"
/>
{activeLayers.map((layer) => {
const model = series.models.find((m) => m.key === layer.key);
const dotY = chart.baseY - (layer.y1 / chart.maxTokens) * chart.UH;
return (
<circle
key={layer.key}
cx={activeViewboxX}
cy={dotY}
r="3.5"
fill="var(--theme-fffdf8)"
stroke={model?.color ?? '#3b82f6'}
strokeWidth="2"
vectorEffect="non-scaling-stroke"
className="usage-trend-dot"
/>
);
})}
</g>
) : null}
</svg>
{active && hoveredIndex != null ? (
<div
className={`usage-trend-tooltip${activePercent > 62 ? ' is-left' : ' is-right'}`}
style={{ left: `${activePercent}%` }}
>
<div className="usage-trend-tooltip-header">
<strong>{formatTrendRangeLabel(active, locale, series.bucket)}</strong>
<span className="usage-trend-tooltip-total">
{compactNumber(activeTotal)} {t('usage.trend.tooltip.tokens')}
</span>
</div>
{activeLayers.length > 0 ? (
<div className="usage-trend-tooltip-list">
{activeLayers.map((layer) => {
const model = series.models.find((m) => m.key === layer.key);
return (
<div key={layer.key} className="usage-trend-tooltip-row">
<span className="usage-trend-swatch" style={{ background: model?.color }} />
<span className="usage-trend-tooltip-label">
{modelLabel(layer.key, model?.label ?? layer.key)}
{model?.star ? <em>{t('usage.trend.star')}</em> : null}
</span>
<b>{compactNumber(layer.tokens)}</b>
</div>
);
})}
</div>
) : (
<div className="usage-trend-tooltip-empty">{t('usage.unit.tokens')}: 0</div>
)}
</div>
) : null}
</div>
<div className="usage-trend-x-axis" aria-hidden="true">
{chart.labelIndexes.map((idx) => {
const point = series.points[idx];
if (!point) return null;
const left = count <= 1 ? 50 : (idx / (count - 1)) * 100;
const posClass = idx === 0 ? 'is-start' : idx === count - 1 ? 'is-end' : 'is-mid';
return (
<span
key={`axis-${point.hour}-${idx}`}
className={posClass}
style={{ left: `${left}%` }}
>
{formatTrendAxisLabel(point, series.bucket, locale, { compactSameDay: chart.compactSameDay })}
</span>
);
})}
</div>
</div>
<span className="sr-only" aria-live="polite">
{srText}
</span>
</div>
);
}
+533
View File
@@ -0,0 +1,533 @@
export type UsageTimelineModel = {
key: string;
label: string;
tokens: number;
requests?: number;
};
export type UsageTimelinePoint = {
hour: string;
requests: number;
success: number;
failure: number;
canceled: number;
tokens: number;
models?: UsageTimelineModel[];
};
export type TrendBucket = 'hour' | '3h' | 'day' | 'week' | 'month' | 'year';
export type PreparedTrendPoint = {
hour: string;
requests: number;
success: number;
failure: number;
canceled: number;
tokens: number;
models: Record<string, number>;
start: Date;
end: Date;
};
export type PreparedTrendModel = {
key: string;
label: string;
tokens: number;
color: string;
star: boolean;
};
export type PreparedTrendSeries = {
bucket: TrendBucket;
points: PreparedTrendPoint[];
totals: {
requests: number;
tokens: number;
failures: number;
success: number;
canceled: number;
};
peak: PreparedTrendPoint | null;
models: PreparedTrendModel[];
};
export type ChartPoint = { x: number; y: number };
const MAX_POINTS = 96;
const MAX_VISIBLE_MODELS = 6;
const OTHER_MODEL_KEY = '__other__';
const HOUR_MS = 60 * 60 * 1000;
const MODEL_COLORS = [
'#3b82f6',
'#10b981',
'#8b5cf6',
'#f59e0b',
'#ec4899',
'#06b6d4',
];
export function parseLocalHourKey(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})-(\d{2})$/.exec(value.trim());
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
if (
!Number.isInteger(year)
|| !Number.isInteger(month)
|| !Number.isInteger(day)
|| !Number.isInteger(hour)
|| month < 1
|| month > 12
|| day < 1
|| day > 31
|| hour < 0
|| hour > 23
) {
return null;
}
const date = new Date(year, month - 1, day, hour, 0, 0, 0);
if (
date.getFullYear() !== year
|| date.getMonth() !== month - 1
|| date.getDate() !== day
|| date.getHours() !== hour
) {
return null;
}
return date;
}
export function formatLocalHourKey(date: Date): string {
const year = String(date.getFullYear()).padStart(4, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
return `${year}-${month}-${day}-${hour}`;
}
function parseRangeDate(value: string | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function cloneDate(date: Date): Date {
return new Date(date.getTime());
}
export function startOfBucket(date: Date, bucket: TrendBucket): Date {
const next = cloneDate(date);
next.setMinutes(0, 0, 0);
if (bucket === 'hour') return next;
if (bucket === '3h') {
next.setHours(Math.floor(next.getHours() / 3) * 3);
return next;
}
next.setHours(0, 0, 0, 0);
if (bucket === 'day') return next;
if (bucket === 'week') {
const weekday = next.getDay();
const offset = weekday === 0 ? -6 : 1 - weekday;
next.setDate(next.getDate() + offset);
return next;
}
next.setDate(1);
if (bucket === 'month') return next;
next.setMonth(0, 1);
return next;
}
export function addBucket(date: Date, bucket: TrendBucket): Date {
const next = cloneDate(date);
if (bucket === 'hour') {
next.setHours(next.getHours() + 1);
return next;
}
if (bucket === '3h') {
next.setHours(next.getHours() + 3);
return next;
}
if (bucket === 'day') {
next.setDate(next.getDate() + 1);
return next;
}
if (bucket === 'week') {
next.setDate(next.getDate() + 7);
return next;
}
if (bucket === 'month') {
next.setMonth(next.getMonth() + 1);
return next;
}
next.setFullYear(next.getFullYear() + 1);
return next;
}
export function endOfBucket(start: Date, bucket: TrendBucket): Date {
return addBucket(start, bucket);
}
function estimateBucketCount(start: Date, end: Date, bucket: TrendBucket): number {
if (end.getTime() <= start.getTime()) return 1;
if (bucket === 'hour') {
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / HOUR_MS));
}
if (bucket === '3h') {
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (3 * HOUR_MS)));
}
let count = 0;
let cursor = startOfBucket(start, bucket);
const limit = startOfBucket(end, bucket);
while (cursor.getTime() <= limit.getTime() && count <= MAX_POINTS + 2) {
count += 1;
cursor = addBucket(cursor, bucket);
}
return Math.max(1, count);
}
const BUCKET_ORDER: TrendBucket[] = ['hour', '3h', 'day', 'week', 'month', 'year'];
export function chooseTrendBucket(start: Date, end: Date): TrendBucket {
for (const bucket of BUCKET_ORDER) {
if (estimateBucketCount(start, end, bucket) <= MAX_POINTS) return bucket;
}
return 'year';
}
function emptyTotals() {
return { requests: 0, tokens: 0, failures: 0, success: 0, canceled: 0 };
}
function addTotals(
target: ReturnType<typeof emptyTotals>,
point: Pick<UsageTimelinePoint, 'requests' | 'tokens' | 'failure' | 'success' | 'canceled'>,
) {
target.requests += point.requests;
target.tokens += point.tokens;
target.failures += point.failure;
target.success += point.success;
target.canceled += point.canceled;
}
function emptyPoint(start: Date, bucket: TrendBucket): PreparedTrendPoint {
return {
hour: formatLocalHourKey(start),
requests: 0,
success: 0,
failure: 0,
canceled: 0,
tokens: 0,
models: {},
start,
end: endOfBucket(start, bucket),
};
}
function modelColor(index: number, key: string): string {
if (key === OTHER_MODEL_KEY) return '#64748b';
return MODEL_COLORS[index % MODEL_COLORS.length];
}
export function niceCeiling(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 1;
const exponent = Math.floor(Math.log10(value));
const magnitude = 10 ** exponent;
const normalized = value / magnitude;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
export function trendAxisTicks(max: number): number[] {
if (max <= 1) return [0, 1];
if (Number.isInteger(max) && max <= 8) {
const step = max <= 4 ? 1 : 2;
const ticks: number[] = [];
for (let value = 0; value <= max; value += step) ticks.push(value);
if (ticks[ticks.length - 1] !== max) ticks.push(max);
return ticks;
}
const mid = max / 2;
return Number.isInteger(mid) ? [0, mid, max] : [0, max];
}
export function selectTrendAxisLabels(count: number, maxLabels = 6): number[] {
if (count <= 0) return [];
if (count <= maxLabels) return Array.from({ length: count }, (_, index) => index);
const last = count - 1;
const indexes = new Set<number>([0, last]);
const inner = maxLabels - 2;
for (let step = 1; step <= inner; step += 1) {
indexes.add(Math.round((last * step) / (inner + 1)));
}
return [...indexes].sort((left, right) => left - right);
}
function sameCalendarDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear()
&& left.getMonth() === right.getMonth()
&& left.getDate() === right.getDate()
);
}
function formatTime(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date);
}
function formatMonthDay(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, { month: 'numeric', day: 'numeric' }).format(date);
}
function formatYearMonth(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short' }).format(date);
}
function formatYear(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, { year: 'numeric' }).format(date);
}
export function formatTrendAxisLabel(
point: PreparedTrendPoint,
bucket: TrendBucket,
locale: string,
options?: { compactSameDay?: boolean },
): string {
if (bucket === 'hour' || bucket === '3h') {
if (options?.compactSameDay) return formatTime(point.start, locale);
return `${formatMonthDay(point.start, locale)} ${formatTime(point.start, locale)}`;
}
if (bucket === 'day') return formatMonthDay(point.start, locale);
if (bucket === 'week') return formatMonthDay(point.start, locale);
if (bucket === 'month') return formatYearMonth(point.start, locale);
return formatYear(point.start, locale);
}
export function formatTrendRangeLabel(
point: PreparedTrendPoint,
locale: string,
bucket: TrendBucket = 'hour',
): string {
const start = point.start;
const end = new Date(Math.max(start.getTime(), point.end.getTime() - 1));
if (bucket === 'year') return formatYear(start, locale);
if (bucket === 'month') return formatYearMonth(start, locale);
if (bucket === 'day') {
const withYear = start.getFullYear() !== new Date().getFullYear();
return new Intl.DateTimeFormat(locale, {
year: withYear ? 'numeric' : undefined,
month: 'numeric',
day: 'numeric',
}).format(start);
}
if (bucket === 'week') {
const withYear = start.getFullYear() !== end.getFullYear();
const formatter = new Intl.DateTimeFormat(locale, {
year: withYear ? 'numeric' : undefined,
month: 'numeric',
day: 'numeric',
});
return `${formatter.format(start)}-${formatter.format(end)}`;
}
const sameDay = sameCalendarDay(start, end);
const dayPart = formatMonthDay(start, locale);
if (sameDay) return `${dayPart} ${formatTime(start, locale)}-${formatTime(end, locale)}`;
return `${dayPart} ${formatTime(start, locale)}-${formatMonthDay(end, locale)} ${formatTime(end, locale)}`;
}
function addModelTokens(target: Record<string, number>, key: string, tokens: number) {
if (!key || tokens <= 0) return;
target[key] = (target[key] ?? 0) + tokens;
}
export function buildUsageTrendSeries(
points: UsageTimelinePoint[],
range?: { start?: string; end?: string },
now = new Date(),
): PreparedTrendSeries {
const labels = new Map<string, string>();
const parsed = points
.map((point) => {
const start = parseLocalHourKey(point.hour);
if (!start) return null;
const models: Record<string, number> = {};
for (const model of point.models ?? []) {
const key = (model.key || model.label || '').trim() || 'unknown';
const label = (model.label || model.key || key).trim() || key;
labels.set(key, label);
addModelTokens(models, key, Math.max(0, model.tokens || 0));
}
const tokens = Math.max(0, point.tokens || 0);
const namedTokens = Object.values(models).reduce((sum, value) => sum + value, 0);
if (tokens > namedTokens) addModelTokens(models, 'unknown', tokens - namedTokens);
if (!Object.keys(models).length && tokens > 0) addModelTokens(models, 'unknown', tokens);
return {
hour: point.hour,
requests: Math.max(0, point.requests || 0),
success: Math.max(0, point.success || 0),
failure: Math.max(0, point.failure || 0),
canceled: Math.max(0, point.canceled || 0),
tokens,
models,
start,
};
})
.filter((point): point is UsageTimelinePoint & { start: Date; models: Record<string, number> } => point !== null)
.sort((left, right) => left.start.getTime() - right.start.getTime());
const totals = emptyTotals();
const modelTotals = new Map<string, number>();
for (const point of parsed) {
addTotals(totals, point);
for (const [key, tokens] of Object.entries(point.models)) {
modelTotals.set(key, (modelTotals.get(key) ?? 0) + tokens);
}
}
if (parsed.length === 0) {
return { bucket: 'hour', points: [], totals, peak: null, models: [] };
}
const rangeStart = parseRangeDate(range?.start);
const rangeEnd = parseRangeDate(range?.end);
const first = parsed[0].start;
const last = parsed[parsed.length - 1].start;
const spanStart = rangeStart
? new Date(Math.min(rangeStart.getTime(), first.getTime()))
: first;
const spanEndSource = rangeEnd ?? (rangeStart ? now : new Date(last.getTime() + HOUR_MS));
const spanEnd = new Date(Math.max(spanEndSource.getTime(), last.getTime() + HOUR_MS));
const bucket = chooseTrendBucket(spanStart, spanEnd);
const seriesStart = startOfBucket(spanStart, bucket);
const seriesEnd = startOfBucket(new Date(Math.max(spanEnd.getTime() - 1, seriesStart.getTime())), bucket);
const buckets = new Map<number, PreparedTrendPoint>();
let cursor = cloneDate(seriesStart);
let guard = 0;
while (cursor.getTime() <= seriesEnd.getTime() && guard <= 400) {
buckets.set(cursor.getTime(), emptyPoint(cloneDate(cursor), bucket));
cursor = addBucket(cursor, bucket);
guard += 1;
}
for (const point of parsed) {
const key = startOfBucket(point.start, bucket).getTime();
const current = buckets.get(key) ?? emptyPoint(startOfBucket(point.start, bucket), bucket);
current.requests += point.requests;
current.success += point.success;
current.failure += point.failure;
current.canceled += point.canceled;
current.tokens += point.tokens;
for (const [modelKey, tokens] of Object.entries(point.models)) {
addModelTokens(current.models, modelKey, tokens);
}
buckets.set(key, current);
}
const series = [...buckets.values()].sort((left, right) => left.start.getTime() - right.start.getTime());
const ranked = [...modelTotals.entries()]
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
const visible = ranked.slice(0, MAX_VISIBLE_MODELS);
const rest = ranked.slice(MAX_VISIBLE_MODELS);
const models: PreparedTrendModel[] = visible.map(([key, tokens], index) => ({
key,
label: labels.get(key) ?? key,
tokens,
color: modelColor(index, key),
star: index === 0 && key !== OTHER_MODEL_KEY,
}));
if (rest.length) {
models.push({
key: OTHER_MODEL_KEY,
label: OTHER_MODEL_KEY,
tokens: rest.reduce((sum, [, tokens]) => sum + tokens, 0),
color: modelColor(models.length, OTHER_MODEL_KEY),
star: models.length === 0,
});
}
const peak = series.reduce<PreparedTrendPoint | null>((best, point) => {
if (point.tokens <= 0) return best;
if (!best || point.tokens > best.tokens) return point;
return best;
}, null);
return { bucket, points: series, totals, peak, models };
}
export function stackModelTokens(
point: PreparedTrendPoint,
models: PreparedTrendModel[],
hiddenKeys: ReadonlySet<string> = new Set(),
): Array<{ key: string; tokens: number; y0: number; y1: number }> {
const visible = models.filter((model) => !hiddenKeys.has(model.key));
const rankedKeys = new Set(models.filter((model) => model.key !== OTHER_MODEL_KEY).map((model) => model.key));
let cursor = 0;
return visible.map((model) => {
const tokens = model.key === OTHER_MODEL_KEY
? Object.entries(point.models).reduce((sum, [key, value]) => (rankedKeys.has(key) ? sum : sum + value), 0)
: point.models[model.key] ?? 0;
const y0 = cursor;
cursor += tokens;
return { key: model.key, tokens, y0, y1: cursor };
});
}
function monotoneTangents(points: ChartPoint[]): number[] {
const count = points.length;
const delta = Array.from({ length: Math.max(0, count - 1) }, () => 0);
const tangent = Array.from({ length: count }, () => 0);
for (let index = 0; index < count - 1; index += 1) {
const dx = points[index + 1].x - points[index].x;
delta[index] = dx === 0 ? 0 : (points[index + 1].y - points[index].y) / dx;
}
if (count > 0) tangent[0] = delta[0] ?? 0;
if (count > 1) tangent[count - 1] = delta[count - 2] ?? 0;
for (let index = 1; index < count - 1; index += 1) {
tangent[index] = delta[index - 1] * delta[index] <= 0 ? 0 : (delta[index - 1] + delta[index]) / 2;
}
for (let index = 0; index < count - 1; index += 1) {
if (delta[index] === 0) {
tangent[index] = 0;
tangent[index + 1] = 0;
continue;
}
const alpha = tangent[index] / delta[index];
const beta = tangent[index + 1] / delta[index];
const square = alpha * alpha + beta * beta;
if (square > 9) {
const scale = 3 / Math.sqrt(square);
tangent[index] = scale * alpha * delta[index];
tangent[index + 1] = scale * beta * delta[index];
}
}
return tangent;
}
export function smoothLinePath(points: ChartPoint[]): string {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
if (points.length === 2) {
return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)} L ${points[1].x.toFixed(2)} ${points[1].y.toFixed(2)}`;
}
const tangent = monotoneTangents(points);
let path = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
for (let index = 0; index < points.length - 1; index += 1) {
const start = points[index];
const end = points[index + 1];
const dx = (end.x - start.x) / 3;
path += ` C ${(start.x + dx).toFixed(2)} ${(start.y + tangent[index] * dx).toFixed(2)} ${(end.x - dx).toFixed(2)} ${(end.y - tangent[index + 1] * dx).toFixed(2)} ${end.x.toFixed(2)} ${end.y.toFixed(2)}`;
}
return path;
}
export function smoothAreaPath(top: ChartPoint[], bottom: ChartPoint[]): string {
if (!top.length || top.length !== bottom.length) return '';
const topPath = smoothLinePath(top);
const bottomPath = smoothLinePath([...bottom].reverse());
if (!topPath || !bottomPath) return '';
return `${topPath} ${bottomPath.replace(/^M /, 'L ')} Z`;
}
export const OTHER_TREND_MODEL_KEY = OTHER_MODEL_KEY;
+359 -41
View File
@@ -7636,13 +7636,45 @@ textarea:focus-visible {
}
.usage-records-page {
display: grid;
display: flex;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
align-content: start;
justify-items: stretch;
overflow: hidden;
}
.app-shell:has(.usage-records-page) {
height: 100%;
min-height: 100%;
}
.app-shell:has(.usage-records-page) .workspace {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
}
.app-shell:has(.usage-records-page) .content {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
}
.usage-records-page > .usage-overview-layout,
.usage-records-page > .usage-analysis-grid,
.usage-records-page > .usage-events-panel,
.usage-records-page > .usage-pricing-panel,
.usage-records-page > .usage-data-management-panel,
.usage-records-page > .usage-initial-loading {
flex: 1 1 auto;
min-height: 0;
}
.usage-topbar {
@@ -7905,8 +7937,10 @@ textarea:focus-visible {
/* Overview Layout */
.usage-overview-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr);
grid-template-rows: auto minmax(180px, 1fr);
gap: 14px;
align-items: stretch;
}
.usage-stat-grid {
@@ -7960,12 +7994,21 @@ textarea:focus-visible {
.usage-health-panel,
.usage-category-panel,
.usage-events-panel {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
padding: 16px;
border: 1px solid var(--theme-e3e1db);
border-radius: 9px;
background: var(--theme-fffdf8);
}
.usage-trend-panel,
.usage-health-panel {
height: 100%;
}
.usage-section-heading {
display: flex;
align-items: center;
@@ -7982,6 +8025,7 @@ textarea:focus-visible {
color: var(--theme-2d2a26);
font-size: var(--font-size-body);
font-weight: 700;
white-space: nowrap;
}
.usage-section-heading span {
@@ -7991,21 +8035,92 @@ textarea:focus-visible {
.usage-trend-wrapper {
display: grid;
gap: 10px;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
}
.usage-trend-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.usage-trend-legend {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.usage-trend-header-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
}
.usage-trend-legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: var(--theme-7a746c);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.usage-trend-legend-item:hover {
background: var(--theme-f4f1ea);
color: var(--theme-2d2a26);
}
.usage-trend-legend-item.is-star {
color: var(--theme-2d2a26);
font-weight: 600;
}
.usage-trend-legend-item em {
font-style: normal;
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
background: color-mix(in srgb, #3b82f6 12%, transparent);
color: #2563eb;
line-height: 1;
}
:root[data-theme='dark'] .usage-trend-legend-item em {
color: #93c5fd;
background: rgba(59, 130, 246, 0.2);
}
.usage-trend-legend-item.is-hidden {
opacity: 0.38;
text-decoration: line-through;
}
.usage-trend-swatch {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
display: inline-block;
}
.usage-trend-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
padding: 2px 7px;
border-radius: 6px;
background: var(--theme-f4f1ea);
color: var(--theme-7a746c);
@@ -8017,38 +8132,223 @@ textarea:focus-visible {
font-weight: 600;
}
.usage-trend {
.usage-trend-chart {
display: grid;
gap: 8px;
min-height: 0;
height: 100%;
grid-template-columns: 36px minmax(0, 1fr);
grid-template-rows: minmax(140px, 1fr) 18px;
column-gap: 8px;
row-gap: 4px;
}
.usage-trend svg {
.usage-trend-y-axis {
grid-column: 1;
grid-row: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
padding: 2px 0;
color: var(--theme-8a847c);
font-size: 10px;
font-variant-numeric: tabular-nums;
line-height: 1;
user-select: none;
}
.usage-trend-plot {
position: relative;
grid-column: 2;
grid-row: 1;
min-width: 0;
min-height: 0;
height: 100%;
outline: none;
cursor: crosshair;
}
.usage-trend-plot:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, #3b82f6 35%, transparent);
border-radius: 6px;
}
.usage-trend-svg {
display: block;
width: 100%;
height: 180px;
height: 100%;
overflow: visible;
border-bottom: 1px solid var(--theme-e3e1db);
}
.usage-trend polyline {
stroke: #3b82f6;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
.usage-trend-grid {
stroke: var(--theme-eeeae2);
stroke-dasharray: 3 3;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.usage-trend-labels {
.usage-trend-baseline {
stroke: var(--theme-e3e1db);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.usage-trend-area {
transition: opacity 0.2s ease;
}
.usage-trend-line {
vector-effect: non-scaling-stroke;
}
.usage-trend-total-line {
opacity: 0.5;
vector-effect: non-scaling-stroke;
}
.usage-trend-cursor-line {
stroke: var(--theme-7a746c);
stroke-dasharray: 3 2;
stroke-width: 1;
opacity: 0.65;
vector-effect: non-scaling-stroke;
}
.usage-trend-dot {
vector-effect: non-scaling-stroke;
}
.usage-trend-x-axis {
position: relative;
grid-column: 2;
grid-row: 2;
height: 18px;
}
.usage-trend-x-axis span {
position: absolute;
top: 2px;
color: var(--theme-8a847c);
font-size: 10px;
font-variant-numeric: tabular-nums;
transform: translateX(-50%);
white-space: nowrap;
user-select: none;
}
.usage-trend-x-axis span.is-start {
left: 0 !important;
transform: none;
}
.usage-trend-x-axis span.is-end {
left: 100% !important;
transform: translateX(-100%);
}
.usage-trend-tooltip {
position: absolute;
top: 6px;
z-index: 10;
min-width: 140px;
max-width: 260px;
padding: 8px 10px;
border: 1px solid var(--theme-e3e1db);
border-radius: 8px;
background: var(--theme-fffdf8);
box-shadow: 0 4px 16px rgba(45, 42, 38, 0.12);
pointer-events: none;
font-size: 11px;
}
:root[data-theme='dark'] .usage-trend-tooltip {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.usage-trend-tooltip.is-right {
transform: translateX(12px);
}
.usage-trend-tooltip.is-left {
transform: translateX(calc(-100% - 12px));
}
.usage-trend-tooltip-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 5px;
border-bottom: 1px solid var(--theme-eeeae2);
margin-bottom: 5px;
}
.usage-trend-tooltip-header strong {
color: var(--theme-2d2a26);
font-size: 11px;
font-weight: 600;
}
.usage-trend-tooltip-total {
color: var(--theme-3f6f98);
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.usage-trend-tooltip-list {
display: grid;
gap: 4px;
}
.usage-trend-tooltip-row {
display: flex;
align-items: center;
gap: 6px;
color: var(--theme-7a746c);
}
.usage-trend-tooltip-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-trend-tooltip-label em {
margin-left: 4px;
font-style: normal;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: color-mix(in srgb, #3b82f6 14%, transparent);
color: #2563eb;
line-height: 1;
}
:root[data-theme='dark'] .usage-trend-tooltip-label em {
color: #93c5fd;
background: rgba(59, 130, 246, 0.2);
}
.usage-trend-tooltip-row b {
color: var(--theme-2d2a26);
font-variant-numeric: tabular-nums;
}
.usage-trend-tooltip-empty {
color: var(--theme-8a847c);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
/* Token Breakdown Panel */
.usage-token-breakdown {
display: grid;
min-height: 0;
align-content: start;
gap: 12px;
overflow: auto;
}
.usage-token-row {
@@ -8132,7 +8432,10 @@ textarea:focus-visible {
.usage-category-list {
display: grid;
min-height: 0;
align-content: start;
gap: 10px;
overflow: auto;
}
.usage-category-row {
@@ -8566,9 +8869,9 @@ textarea:focus-visible {
position: relative;
overflow-x: auto;
overflow-y: auto;
height: max(480px, calc(100vh - 300px));
height: max(480px, calc(100dvh - 300px));
min-height: 0;
flex: 1 1 auto;
min-height: 240px;
height: auto;
border: 0;
border-radius: 0;
background: var(--theme-fffdf8);
@@ -8836,14 +9139,18 @@ body.table-col-resizing * {
}
.usage-events-panel > .usage-empty {
min-height: 480px;
flex: 1 1 auto;
min-height: 240px;
}
@media (max-height: 760px) {
.usage-table-wrap {
height: max(360px, calc(100vh - 300px));
height: max(360px, calc(100dvh - 300px));
}
.usage-analysis-grid {
align-content: start;
overflow: auto;
}
.usage-pricing-panel {
min-height: 0;
overflow: auto;
}
/* Pricing Tab */
@@ -8858,7 +9165,9 @@ body.table-col-resizing * {
}
.usage-data-management-panel {
min-height: 0;
padding: 24px;
overflow: auto;
}
.usage-data-management-heading,
@@ -10040,11 +10349,6 @@ body.table-col-resizing * {
grid-template-columns: 200px minmax(0, 1fr);
}
.usage-overview-layout {
grid-template-columns: 1fr;
gap: 12px;
}
.usage-stat-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
@@ -10061,6 +10365,27 @@ body.table-col-resizing * {
}
@media (max-width: 760px) {
.app-shell:has(.usage-records-page) {
height: auto;
min-height: 100%;
}
.app-shell:has(.usage-records-page) .content {
overflow: visible;
}
.usage-records-page {
flex: none;
overflow: visible;
}
.usage-table-wrap,
.usage-events-panel > .usage-empty {
min-height: 320px;
height: min(70vh, 640px);
flex: none;
}
.app-shell {
grid-template-columns: 1fr;
}
@@ -10197,7 +10522,6 @@ body.table-col-resizing * {
grid-template-columns: 1fr;
}
.usage-overview-layout,
.usage-analysis-grid {
grid-template-columns: 1fr;
}
@@ -13693,7 +14017,6 @@ body.table-col-resizing * {
.provider-workbench,
.real-provider-workbench,
.kernel-layout,
.usage-overview-layout,
.config-management-content {
grid-template-columns: 1fr;
}
@@ -13719,10 +14042,6 @@ body.table-col-resizing * {
grid-template-columns: 200px minmax(0, 1fr);
}
.usage-overview-layout {
gap: 12px;
}
.usage-stat-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
@@ -13772,7 +14091,6 @@ body.table-col-resizing * {
flex: 1;
}
.usage-overview-layout,
.usage-analysis-grid {
grid-template-columns: 1fr;
}
+226
View File
@@ -0,0 +1,226 @@
import { describe, expect, test } from 'bun:test';
import {
OTHER_TREND_MODEL_KEY,
buildUsageTrendSeries,
chooseTrendBucket,
formatLocalHourKey,
formatTrendAxisLabel,
formatTrendRangeLabel,
niceCeiling,
parseLocalHourKey,
selectTrendAxisLabels,
smoothAreaPath,
smoothLinePath,
stackModelTokens,
startOfBucket,
trendAxisTicks,
type UsageTimelinePoint,
} from '../src/services/usageTrend';
const point = (
hour: string,
requests: number,
tokens: number,
extras: Partial<UsageTimelinePoint> = {},
): UsageTimelinePoint => ({
hour,
requests,
tokens,
success: extras.success ?? requests,
failure: extras.failure ?? 0,
canceled: extras.canceled ?? 0,
});
describe('usage trend helpers', () => {
test('parses and formats local hour keys', () => {
const date = parseLocalHourKey('2026-09-14-15');
expect(date).not.toBeNull();
expect(date?.getFullYear()).toBe(2026);
expect(date?.getMonth()).toBe(8);
expect(date?.getDate()).toBe(14);
expect(date?.getHours()).toBe(15);
expect(formatLocalHourKey(date as Date)).toBe('2026-09-14-15');
expect(parseLocalHourKey('2026-02-30-10')).toBeNull();
});
test('chooses coarser buckets as the range grows', () => {
const hourStart = new Date(2026, 8, 14, 12, 0, 0);
expect(chooseTrendBucket(hourStart, new Date(2026, 8, 15, 12, 0, 0))).toBe('hour');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 8, 0, 0, 0))).toBe('3h');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 16, 0, 0, 0))).toBe('day');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 9, 1, 0, 0, 0))).toBe('day');
expect(chooseTrendBucket(new Date(2025, 0, 1, 0, 0, 0), new Date(2026, 6, 1, 0, 0, 0))).toBe('week');
expect(chooseTrendBucket(new Date(2022, 0, 1, 0, 0, 0), new Date(2026, 0, 1, 0, 0, 0))).toBe('month');
expect(chooseTrendBucket(new Date(2018, 0, 1, 0, 0, 0), new Date(2026, 0, 1, 0, 0, 0))).toBe('year');
});
test('fills idle hours inside the selected range', () => {
const start = new Date(2026, 8, 14, 10, 0, 0);
const end = new Date(2026, 8, 14, 14, 0, 0);
const series = buildUsageTrendSeries(
[
point('2026-09-14-10', 2, 20),
point('2026-09-14-12', 1, 10, { success: 0, failure: 1 }),
],
{ start: start.toISOString(), end: end.toISOString() },
);
expect(series.bucket).toBe('hour');
expect(series.points.map((item) => item.hour)).toEqual([
'2026-09-14-10',
'2026-09-14-11',
'2026-09-14-12',
'2026-09-14-13',
]);
expect(series.points[1]).toMatchObject({ requests: 0, tokens: 0 });
expect(series.points[2]).toMatchObject({ requests: 1, failure: 1, tokens: 10 });
expect(series.totals).toMatchObject({ requests: 3, tokens: 30, failures: 1 });
expect(series.peak?.hour).toBe('2026-09-14-10');
expect(series.peak?.tokens).toBe(20);
});
test('fills idle hours before the first event in the selected range', () => {
const start = new Date(2026, 8, 14, 8, 0, 0);
const end = new Date(2026, 8, 14, 12, 0, 0);
const series = buildUsageTrendSeries(
[point('2026-09-14-10', 2, 20)],
{ start: start.toISOString(), end: end.toISOString() },
);
expect(series.bucket).toBe('hour');
expect(series.points.map((item) => item.hour)).toEqual([
'2026-09-14-08',
'2026-09-14-09',
'2026-09-14-10',
'2026-09-14-11',
]);
expect(series.points[0]).toMatchObject({ requests: 0, tokens: 0 });
expect(series.points[2]).toMatchObject({ requests: 2, tokens: 20 });
});
test('aggregates sparse hours into 3-hour buckets', () => {
const start = new Date(2026, 8, 1, 0, 0, 0);
const end = new Date(2026, 8, 8, 0, 0, 0);
const series = buildUsageTrendSeries(
[
point('2026-09-01-01', 1, 5),
point('2026-09-01-02', 3, 7, { success: 2, failure: 1 }),
],
{ start: start.toISOString(), end: end.toISOString() },
);
expect(series.bucket).toBe('3h');
const first = series.points[0];
expect(startOfBucket(first.start, '3h').getHours()).toBe(0);
expect(first).toMatchObject({ requests: 4, tokens: 12, failure: 1, success: 3 });
expect(series.points.length).toBeGreaterThan(40);
});
test('uses daily buckets and fills idle days for a 30-day range', () => {
const start = new Date(2026, 7, 15, 8, 0, 0);
const end = new Date(2026, 8, 14, 18, 0, 0);
const series = buildUsageTrendSeries(
[
point('2026-08-15-09', 2, 8),
point('2026-09-14-10', 5, 20, { success: 4, failure: 1 }),
],
{ start: start.toISOString(), end: end.toISOString() },
);
expect(series.bucket).toBe('day');
expect(series.points[0]).toMatchObject({ requests: 2, tokens: 8 });
expect(series.points[series.points.length - 1]).toMatchObject({ requests: 5, tokens: 20, failure: 1 });
expect(series.points.some((item) => item.requests === 0)).toBe(true);
expect(series.points.length).toBeGreaterThan(20);
});
test('starts week buckets on Monday', () => {
const thursday = new Date(2026, 0, 1, 12, 0, 0);
const monday = startOfBucket(thursday, 'week');
expect(monday.getDay()).toBe(1);
expect(monday.getFullYear()).toBe(2025);
expect(monday.getMonth()).toBe(11);
expect(monday.getDate()).toBe(29);
});
test('stacks model tokens and groups overflow models', () => {
const start = new Date(2026, 8, 14, 10, 0, 0);
const end = new Date(2026, 8, 14, 12, 0, 0);
const models = Array.from({ length: 8 }, (_, index) => ({
key: `model-${index}`,
label: `Model ${index}`,
tokens: (8 - index) * 10,
}));
const series = buildUsageTrendSeries(
[
{
hour: '2026-09-14-10',
requests: 1,
success: 1,
failure: 0,
canceled: 0,
tokens: models.reduce((sum, model) => sum + model.tokens, 0),
models,
},
],
{ start: start.toISOString(), end: end.toISOString() },
);
expect(series.models.map((model) => model.key)).toEqual([
'model-0',
'model-1',
'model-2',
'model-3',
'model-4',
'model-5',
OTHER_TREND_MODEL_KEY,
]);
expect(series.models[0].star).toBe(true);
expect(series.models[1].star).toBe(false);
expect(series.models[0].color).not.toBe(series.models[1].color);
const stacked = stackModelTokens(series.points[0], series.models);
expect(stacked[0]).toMatchObject({ key: 'model-0', tokens: 80, y0: 0, y1: 80 });
expect(stacked[stacked.length - 1].key).toBe(OTHER_TREND_MODEL_KEY);
expect(stacked[stacked.length - 1].y1).toBe(series.points[0].tokens);
const hidden = stackModelTokens(series.points[0], series.models, new Set(['model-0']));
expect(hidden[0].key).toBe('model-1');
expect(hidden[hidden.length - 1].y1).toBe(series.points[0].tokens - 80);
});
test('builds a monotone cubic path that stays inside the value range', () => {
const line = smoothLinePath([
{ x: 0, y: 10 },
{ x: 10, y: 0 },
{ x: 20, y: 10 },
]);
expect(line).toContain('C');
const numbers = [...line.matchAll(/-?\d+(?:\.\d+)?/g)].map((match) => Number(match[0]));
const ys = numbers.filter((_, index) => index % 2 === 1);
expect(Math.min(...ys)).toBeGreaterThanOrEqual(0);
expect(Math.max(...ys)).toBeLessThanOrEqual(10);
expect(smoothAreaPath(
[{ x: 0, y: 4 }, { x: 10, y: 8 }],
[{ x: 0, y: 10 }, { x: 10, y: 10 }],
)).toContain('Z');
});
test('builds readable axis ticks and labels', () => {
expect(niceCeiling(0)).toBe(1);
expect(niceCeiling(12)).toBe(20);
expect(trendAxisTicks(20)).toEqual([0, 10, 20]);
expect(selectTrendAxisLabels(24, 6)[0]).toBe(0);
expect(selectTrendAxisLabels(24, 6).at(-1)).toBe(23);
expect(selectTrendAxisLabels(24, 6)).toHaveLength(6);
const hourPoint = {
...point('2026-09-14-15', 1, 1),
start: new Date(2026, 8, 14, 15, 0, 0),
end: new Date(2026, 8, 14, 16, 0, 0),
};
expect(formatTrendAxisLabel(hourPoint, 'hour', 'en', { compactSameDay: true })).toMatch(/15:00|3:00/);
const rangeLabel = formatTrendRangeLabel(hourPoint, 'en', 'hour');
expect(rangeLabel).toMatch(/9\/14|14\/9/);
expect(rangeLabel).toMatch(/15:00|3:00/);
expect(formatTrendRangeLabel({ ...hourPoint, end: new Date(2026, 8, 15, 0, 0, 0) }, 'en', 'day')).toMatch(/9\/14|14\/9/);
});
});