feat(usage): show 30-minute trend ticks and restore light theme

This commit is contained in:
lzt404 committed 2026-09-15 02:39:03 +08:00
1 parent accb93c75c
commit 8f52a08177
12 files changed
+466 -403

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@
: window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = theme === 'dark' ? '#111412' : '#f6f7f5';
document.documentElement.style.backgroundColor = theme === 'dark' ? '#0b0d11' : '#f6f7f5';
})();
</script>
<title>EasyCLIProxyAPI</title>
+32 -3
View File
@@ -2649,7 +2649,16 @@ fn load_usage_overview(
let timeline_sql = format!(
r#"
SELECT
local_hour,
CASE
WHEN timestamp_ms > 0 THEN
strftime('%Y-%m-%d-%H-', datetime(timestamp_ms / 1000, 'unixepoch', 'localtime'))
|| CASE
WHEN CAST(strftime('%M', datetime(timestamp_ms / 1000, 'unixepoch', 'localtime')) AS INTEGER) < 30
THEN '00'
ELSE '30'
END
ELSE local_hour || '-00'
END,
COALESCE(NULLIF(TRIM(model), ''), 'unknown'),
COUNT(*),
COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0),
@@ -2657,8 +2666,8 @@ fn load_usage_overview(
COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM usage_events{}
GROUP BY local_hour, 2
ORDER BY local_hour ASC, 2 ASC
GROUP BY 1, 2
ORDER BY 1 ASC, 2 ASC
"#,
filter.clause
);
@@ -5541,6 +5550,26 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn timeline_groups_requests_into_half_hour_buckets() {
let root = test_root("sqlite-half-hour");
let mut connection = open_test_database(&root);
let first = sample_record("half-1", "2026-07-17T20:10:00+08:00", "gpt-a");
let second = sample_record("half-2", "2026-07-17T20:40:00+08:00", "gpt-a");
insert_usage_records(&mut connection, &[first, second]).unwrap();
let overview = load_usage_overview(&connection, &UsageQuery::default()).unwrap();
assert_eq!(overview.timeline.len(), 2);
assert_ne!(overview.timeline[0].hour, overview.timeline[1].hour);
assert!(overview
.timeline
.iter()
.all(|point| point.hour.ends_with("-00") || point.hour.ends_with("-30")));
assert_eq!(overview.timeline.iter().map(|point| point.requests).sum::<u64>(), 2);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn overview_tps_uses_weighted_total_latency_without_requiring_ttft() {
let root = test_root("tps-overview");
+24 -22
View File
@@ -10,13 +10,13 @@ import {
History,
House,
Languages,
Lock,
LogIn,
MessageCircle,
Network,
PackageOpen,
ServerCog,
Settings,
Sparkles,
X,
} from 'lucide-react';
import appLogo from './assets/logo.jpg';
@@ -42,7 +42,7 @@ const pages = [
{
id: 'easy',
labelKey: 'app.nav.easy',
icon: Sparkles,
icon: House,
component: HomePage,
},
{
@@ -52,16 +52,10 @@ const pages = [
component: HomePage,
},
{
id: 'versions',
labelKey: 'app.nav.versions',
icon: PackageOpen,
component: VersionManagementPageWrapper,
},
{
id: 'config',
labelKey: 'app.nav.config',
icon: Settings,
component: ConfigPanelPage,
id: 'api',
labelKey: 'app.nav.api',
icon: Network,
component: ApiAccessPage,
},
{
id: 'oauth',
@@ -70,10 +64,10 @@ const pages = [
component: OAuthManagementPage,
},
{
id: 'api',
labelKey: 'app.nav.api',
icon: Network,
component: ApiAccessPage,
id: 'agents',
labelKey: 'app.nav.agents',
icon: Bot,
component: AgentsPage,
},
{
id: 'usage-records',
@@ -82,10 +76,16 @@ const pages = [
component: UsageRecordsPage,
},
{
id: 'agents',
labelKey: 'app.nav.agents',
icon: Bot,
component: AgentsPage,
id: 'config',
labelKey: 'app.nav.config',
icon: Settings,
component: ConfigPanelPage,
},
{
id: 'versions',
labelKey: 'app.nav.versions',
icon: PackageOpen,
component: VersionManagementPageWrapper,
},
] as const;
@@ -309,12 +309,14 @@ function AppContent() {
.filter(Boolean)
.join(' ')}
disabled={locked}
title={locked ? t('app.coreRequired.title') : undefined}
title={locked ? t('app.nav.lockedHint') : undefined}
onClick={() => select(page.id)}
>
<Icon size={17} aria-hidden="true" />
<span>{t(page.labelKey)}</span>
{updateIndicator ? (
{locked ? (
<Lock size={13} className="nav-lock-icon" aria-hidden="true" />
) : updateIndicator ? (
<i
className={`nav-update-indicator ${updateIndicator}`}
title={updateIndicator === 'processing'
+2 -1
View File
@@ -242,6 +242,7 @@ export const jaOverrides = {
'app.nav.quota': 'クォータ照会',
'app.nav.usageRecords': '使用履歴',
'app.nav.agents': 'エージェント設定',
'app.nav.lockedHint': 'まずホームでコアサービスを起動してください',
'app.coreRequired.title': '先にコアを起動してください',
'app.coreRequired.description': '設定、OAuth、API 接続などを使用するには CPA コアを起動してください。',
'app.contact.title': 'QQ でお問い合わせ',
@@ -468,6 +469,7 @@ export const jaOverrides = {
'usage.token.cacheRead': 'キャッシュ読み取り',
'usage.token.cacheCreation': 'キャッシュ作成',
'usage.trend.aria': 'Token の推移図。マウスまたは矢印キーで各時間帯とモデルの内訳を確認できます。',
'usage.trend.bucket.30m': '30 分ごとに表示',
'usage.trend.bucket.hour': '1 時間ごとに表示',
'usage.trend.bucket.3h': '3 時間ごとに集計',
'usage.trend.bucket.day': '日ごとに集計',
@@ -475,7 +477,6 @@ export const jaOverrides = {
'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': 'リクエスト元',
+2 -1
View File
@@ -191,6 +191,7 @@ export const en: Record<MessageKey, string> = {
'app.nav.quota': 'Quota Lookup',
'app.nav.usageRecords': 'Usage',
'app.nav.agents': 'Agent Configuration',
'app.nav.lockedHint': 'Please start the core service on Home first',
'app.coreRequired.title': 'Start the core first',
'app.coreRequired.description': 'Start the CPA core to use settings, OAuth, API access, and other features.',
'app.contact.title': 'Contact us on QQ',
@@ -456,6 +457,7 @@ export const en: Record<MessageKey, string> = {
'usage.token.cacheRead': 'Cache Read',
'usage.token.cacheCreation': 'Cache Creation',
'usage.trend.aria': 'Token trend chart. Hover or use arrow keys to inspect each bucket and model.',
'usage.trend.bucket.30m': 'Shown every 30 minutes',
'usage.trend.bucket.hour': 'Shown by hour',
'usage.trend.bucket.3h': 'Grouped every 3 hours',
'usage.trend.bucket.day': 'Grouped by day',
@@ -463,7 +465,6 @@ export const en: Record<MessageKey, string> = {
'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',
+2 -1
View File
@@ -190,6 +190,7 @@ export const zhCN = {
'app.nav.quota': '额度查询',
'app.nav.usageRecords': '使用记录',
'app.nav.agents': '智能体配置',
'app.nav.lockedHint': '请先在首页启动内核服务',
'app.coreRequired.title': '请先启动内核',
'app.coreRequired.description': '启动 CPA 内核后,才能使用配置、OAuth、API 接入等功能。',
'app.contact.title': '通过 QQ 联系我们',
@@ -455,6 +456,7 @@ export const zhCN = {
'usage.token.cacheRead': '缓存读取',
'usage.token.cacheCreation': '缓存创建',
'usage.trend.aria': 'Token 趋势图,可悬停或用方向键查看各时段和模型明细',
'usage.trend.bucket.30m': '按 30 分钟展示',
'usage.trend.bucket.hour': '按小时展示',
'usage.trend.bucket.3h': '按 3 小时聚合',
'usage.trend.bucket.day': '按天聚合',
@@ -462,7 +464,6 @@ export const zhCN = {
'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': '请求来源',
-2
View File
@@ -25,7 +25,6 @@ import {
Route,
Settings2,
ShieldCheck,
Sparkles,
Power,
Terminal,
Trash2,
@@ -2180,7 +2179,6 @@ export function ConfigPanelPage() {
onClick={generateApiKey}
disabled={keyMutationBusy}
>
<Sparkles size={16} aria-hidden="true" />
{t('config.keys.generate')}
</button>
<button type="submit" className="primary-button" disabled={keyMutationBusy}>
+36 -62
View File
@@ -8,19 +8,13 @@ import {
ChevronLeft,
ChevronRight,
CircleDollarSign,
Clock3,
Columns3Cog,
Database,
FilterX,
Key,
Layers,
List,
Pencil,
RefreshCw,
RotateCcw,
ShieldCheck,
Sparkles,
Terminal,
Trash2,
TriangleAlert,
Wrench,
@@ -38,6 +32,7 @@ import {
formatTrendRangeLabel,
niceCeiling,
selectTrendAxisLabels,
trendAxisTicks,
smoothAreaPath,
smoothLinePath,
stackModelTokens,
@@ -555,10 +550,7 @@ export function UsageRecordsPage() {
<div className="usage-filter-row">
<div className="usage-filter-group">
<label className="usage-filter-item">
<span className="usage-filter-label">
<Clock3 size={13} />
{t('usage.filter.timeRange')}
</span>
<span className="usage-filter-label">{t('usage.filter.timeRange')}</span>
<select
value={range}
onChange={(event) => {
@@ -578,10 +570,7 @@ export function UsageRecordsPage() {
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">
<Sparkles size={13} />
{t('usage.filter.model')}
</span>
<span className="usage-filter-label">{t('usage.filter.model')}</span>
<select
value={model}
onChange={(event) => changeFilter(setModel, event.currentTarget.value)}
@@ -597,10 +586,7 @@ export function UsageRecordsPage() {
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">
<Layers size={13} />
{t('usage.column.provider')}
</span>
<span className="usage-filter-label">{t('usage.column.provider')}</span>
<select
value={provider}
onChange={(event) => changeFilter(setProvider, event.currentTarget.value)}
@@ -616,10 +602,7 @@ export function UsageRecordsPage() {
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">
<Terminal size={13} />
{t('usage.filter.source')}
</span>
<span className="usage-filter-label">{t('usage.filter.source')}</span>
<select
value={source}
onChange={(event) => changeFilter(setSource, event.currentTarget.value)}
@@ -635,10 +618,7 @@ export function UsageRecordsPage() {
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">
<Key size={13} />
{t('apiAccess.field.key')}
</span>
<span className="usage-filter-label">{t('apiAccess.field.key')}</span>
<select
value={apiKeyHash}
onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)}
@@ -654,10 +634,7 @@ export function UsageRecordsPage() {
</label>
<label className="usage-filter-item">
<span className="usage-filter-label">
<ShieldCheck size={13} />
{t('usage.filter.result')}
</span>
<span className="usage-filter-label">{t('usage.filter.result')}</span>
<select
value={result}
onChange={(event) => changeFilter(setResult, event.currentTarget.value)}
@@ -987,6 +964,7 @@ function UsageTrend({
stacked: [],
areaLayers: [],
totalLinePath: '',
yTicks: [0, 0.25, 0.5, 0.75, 1],
labelIndexes: [],
compactSameDay: false,
baseY: 146,
@@ -1050,8 +1028,12 @@ function UsageTrend({
}));
}
const totalLinePath = smoothLinePath(totalPoints);
const yTicks = trendAxisTicks(maxTokens);
const labelIndexes = selectTrendAxisLabels(count, count > 36 ? 5 : 4);
const labelIndexes = selectTrendAxisLabels(
count,
series.bucket === '30m' ? count : count > 48 ? 9 : count > 24 ? 8 : 7,
);
const compactSameDay =
count > 1 &&
series.points[0].start.toDateString() === series.points[count - 1].start.toDateString();
@@ -1061,6 +1043,7 @@ function UsageTrend({
stacked,
areaLayers,
totalLinePath,
yTicks,
labelIndexes,
compactSameDay,
baseY,
@@ -1132,7 +1115,7 @@ function UsageTrend({
<button
type="button"
key={model.key}
className={`usage-trend-legend-item${isHidden ? ' is-hidden' : ''}${model.star ? ' is-star' : ''}`}
className={`usage-trend-legend-item${isHidden ? ' is-hidden' : ''}`}
aria-pressed={!isHidden}
onClick={() =>
setHiddenModels((current) =>
@@ -1144,7 +1127,6 @@ function UsageTrend({
>
<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>
);
})}
@@ -1159,9 +1141,9 @@ function UsageTrend({
<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>
{[...chart.yTicks].reverse().map((tick, index) => (
<span key={`y-${tick}-${index}`}>{compactNumber(tick)}</span>
))}
</div>
<div
@@ -1191,43 +1173,36 @@ function UsageTrend({
<stop
offset="0%"
stopColor={model.color}
stopOpacity={model.star ? 0.38 : 0.28}
stopOpacity={0.28}
/>
<stop
offset="100%"
stopColor={model.color}
stopOpacity={model.star ? 0.14 : 0.08}
stopOpacity={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.yTicks.map((tick) => {
const y = chart.baseY - (tick / chart.maxTokens) * chart.UH;
const isBase = tick === 0;
return (
<line
key={`grid-${tick}`}
x1="0"
y1={y}
x2="1000"
y2={y}
className={isBase ? 'usage-trend-baseline' : 'usage-trend-grid'}
/>
);
})}
{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' : ''}`}>
<g key={layer.model.key} className="usage-trend-layer">
<path
d={layer.areaPath}
fill={`url(#trend-grad-${layer.model.key.replace(/[^a-zA-Z0-9_-]+/g, '-')})`}
@@ -1237,7 +1212,7 @@ function UsageTrend({
d={layer.linePath}
fill="none"
stroke={layer.model.color}
strokeWidth={layer.model.star ? 1.8 : 1.4}
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
@@ -1310,7 +1285,6 @@ function UsageTrend({
<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>
+45 -23
View File
@@ -15,7 +15,7 @@ export type UsageTimelinePoint = {
models?: UsageTimelineModel[];
};
export type TrendBucket = 'hour' | '3h' | 'day' | 'week' | 'month' | 'year';
export type TrendBucket = '30m' | 'hour' | '3h' | 'day' | 'week' | 'month' | 'year';
export type PreparedTrendPoint = {
hour: string;
@@ -34,7 +34,6 @@ export type PreparedTrendModel = {
label: string;
tokens: number;
color: string;
star: boolean;
};
export type PreparedTrendSeries = {
@@ -67,32 +66,37 @@ const MODEL_COLORS = [
];
export function parseLocalHourKey(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})-(\d{2})$/.exec(value.trim());
const match = /^(\d{4})-(\d{2})-(\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]);
const minute = match[5] === undefined ? 0 : Number(match[5]);
if (
!Number.isInteger(year)
|| !Number.isInteger(month)
|| !Number.isInteger(day)
|| !Number.isInteger(hour)
|| !Number.isInteger(minute)
|| month < 1
|| month > 12
|| day < 1
|| day > 31
|| hour < 0
|| hour > 23
|| minute < 0
|| minute > 59
) {
return null;
}
const date = new Date(year, month - 1, day, hour, 0, 0, 0);
const date = new Date(year, month - 1, day, hour, minute, 0, 0);
if (
date.getFullYear() !== year
|| date.getMonth() !== month - 1
|| date.getDate() !== day
|| date.getHours() !== hour
|| date.getMinutes() !== minute
) {
return null;
}
@@ -104,7 +108,8 @@ export function formatLocalHourKey(date: Date): string {
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}`;
const minute = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}-${hour}-${minute}`;
}
function parseRangeDate(value: string | undefined): Date | null {
@@ -119,6 +124,11 @@ function cloneDate(date: Date): Date {
export function startOfBucket(date: Date, bucket: TrendBucket): Date {
const next = cloneDate(date);
next.setSeconds(0, 0);
if (bucket === '30m') {
next.setMinutes(next.getMinutes() < 30 ? 0 : 30, 0, 0);
return next;
}
next.setMinutes(0, 0, 0);
if (bucket === 'hour') return next;
if (bucket === '3h') {
@@ -141,6 +151,10 @@ export function startOfBucket(date: Date, bucket: TrendBucket): Date {
export function addBucket(date: Date, bucket: TrendBucket): Date {
const next = cloneDate(date);
if (bucket === '30m') {
next.setMinutes(next.getMinutes() + 30);
return next;
}
if (bucket === 'hour') {
next.setHours(next.getHours() + 1);
return next;
@@ -171,6 +185,9 @@ export function endOfBucket(start: Date, bucket: TrendBucket): Date {
function estimateBucketCount(start: Date, end: Date, bucket: TrendBucket): number {
if (end.getTime() <= start.getTime()) return 1;
if (bucket === '30m') {
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (HOUR_MS / 2)));
}
if (bucket === 'hour') {
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / HOUR_MS));
}
@@ -187,7 +204,7 @@ function estimateBucketCount(start: Date, end: Date, bucket: TrendBucket): numbe
return Math.max(1, count);
}
const BUCKET_ORDER: TrendBucket[] = ['hour', '3h', 'day', 'week', 'month', 'year'];
const BUCKET_ORDER: TrendBucket[] = ['30m', 'hour', '3h', 'day', 'week', 'month', 'year'];
export function chooseTrendBucket(start: Date, end: Date): TrendBucket {
for (const bucket of BUCKET_ORDER) {
@@ -239,20 +256,27 @@ export function niceCeiling(value: number): number {
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;
export function trendAxisTicks(max: number, targetCount = 5): number[] {
if (!Number.isFinite(max) || max <= 0) return [0, 1];
const parts = Math.max(2, Math.round(targetCount));
if (Number.isInteger(max) && max <= parts) {
return Array.from({ length: max + 1 }, (_, index) => index);
}
const mid = max / 2;
return Number.isInteger(mid) ? [0, mid, max] : [0, max];
const rawStep = max / parts;
const exponent = Math.floor(Math.log10(rawStep));
const magnitude = 10 ** exponent;
const normalized = rawStep / magnitude;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
const step = nice * magnitude;
const ticks: number[] = [];
for (let value = 0; value < max && ticks.length < 12; value += step) {
ticks.push(Number.isInteger(value) ? value : Number(value.toFixed(6)));
}
if (ticks[ticks.length - 1] !== max) ticks.push(max);
return ticks;
}
export function selectTrendAxisLabels(count: number, maxLabels = 6): number[] {
export function selectTrendAxisLabels(count: number, maxLabels = 8): number[] {
if (count <= 0) return [];
if (count <= maxLabels) return Array.from({ length: count }, (_, index) => index);
const last = count - 1;
@@ -294,7 +318,7 @@ export function formatTrendAxisLabel(
locale: string,
options?: { compactSameDay?: boolean },
): string {
if (bucket === 'hour' || bucket === '3h') {
if (bucket === '30m' || bucket === 'hour' || bucket === '3h') {
if (options?.compactSameDay) return formatTime(point.start, locale);
return `${formatMonthDay(point.start, locale)} ${formatTime(point.start, locale)}`;
}
@@ -386,7 +410,7 @@ export function buildUsageTrendSeries(
}
if (parsed.length === 0) {
return { bucket: 'hour', points: [], totals, peak: null, models: [] };
return { bucket: '30m', points: [], totals, peak: null, models: [] };
}
const rangeStart = parseRangeDate(range?.start);
@@ -396,8 +420,8 @@ export function buildUsageTrendSeries(
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 spanEndSource = rangeEnd ?? (rangeStart ? now : new Date(last.getTime() + HOUR_MS / 2));
const spanEnd = new Date(Math.max(spanEndSource.getTime(), last.getTime() + HOUR_MS / 2));
const bucket = chooseTrendBucket(spanStart, spanEnd);
const seriesStart = startOfBucket(spanStart, bucket);
@@ -436,7 +460,6 @@ export function buildUsageTrendSeries(
label: labels.get(key) ?? key,
tokens,
color: modelColor(index, key),
star: index === 0 && key !== OTHER_MODEL_KEY,
}));
if (rest.length) {
models.push({
@@ -444,7 +467,6 @@ export function buildUsageTrendSeries(
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) => {
+290 -265
View File
@@ -110,120 +110,226 @@
--theme-fff8eb: #fff8eb;
--theme-fffdf8: #fffdf8;
--theme-ffffff: #ffffff;
--bg-secondary: #f6f7f5;
--bg-primary: #ffffff;
--bg-tertiary: #eff3f0;
--bg-hover: #f6f8f6;
--bg-quinary: #f9faf9;
--bg-card: #ffffff;
--bg-error-light: #fff1ee;
--text-primary: #202521;
--text-secondary: #556159;
--text-tertiary: #657168;
--text-quaternary: #7d877f;
--text-muted: #657168;
--border-color: #e2e8e3;
--border-secondary: #edf1ee;
--border-primary: #cfd7d0;
--border-hover: #c0c8c1;
--primary-color: #0f766e;
--primary-hover: #0b5d57;
--primary-active: #094c47;
--primary-contrast: #ffffff;
--success-color: #2f6b3f;
--warning-color: #c77b30;
--error-color: #9f2d20;
--danger-color: #9f2d20;
--info-color: #0f766e;
--amber-color: #c77b30;
--amber-text: #8b5b21;
--success-badge-bg: #eef7f0;
--success-badge-text: #2f6b3f;
--success-badge-border: #b6d3bd;
--failure-badge-bg: #fff1ee;
--failure-badge-text: #9f2d20;
--failure-badge-border: #dfb7b0;
--count-badge-bg: #eff3f0;
--count-badge-text: var(--text-primary);
--ui-accent: #0f766e;
--ui-accent-strong: #0b5d57;
--ui-accent-soft: #d9f0ec;
--ui-focus-ring: rgba(15, 118, 110, 0.28);
--ui-accent-shadow: rgba(15, 118, 110, 0.18);
--ui-panel-shadow: 0 8px 24px rgba(32, 42, 36, 0.07);
--shadow-sm: 0 1px 2px rgba(32, 42, 36, 0.06);
}
:root[data-theme='dark'] {
color-scheme: dark;
--theme-2d2a26: #ece9e2;
--theme-2f6b3a: #83d295;
--theme-2f6b3f: #83d29a;
--theme-342f2a: #d3cec6;
--theme-344ca1: #9fb4ff;
--theme-39352f: #d3cfc7;
--theme-3f3550: #cdc8d6;
--theme-3f6f98: #c6d5e1;
--theme-42637f: #c8d3dd;
--theme-433f39: #d4d0c9;
--theme-4d78a8: #a5b7ca;
--theme-4f4a43: #d5d1ca;
--theme-4f825c: #cbdbd0;
--theme-554075: #d0c8dc;
--theme-5d9a6c: #abc4b1;
--theme-5f5a53: #c8cdd5;
--theme-654f91: #b8a4e2;
--theme-655f57: #d8d4cd;
--theme-66709a: #adb1c2;
--theme-6684a1: #acb8c4;
--theme-6d6760: #d8d5ce;
--theme-6f6961: #d9d5ce;
--theme-716b61: #d9d5ce;
--theme-777067: #d9d6cf;
--theme-7a746c: #aab2bd;
--theme-805a24: #e3b778;
--theme-8069ad: #b8a4e2;
--theme-817b73: #dbd7d1;
--theme-866b4d: #dcd4cb;
--theme-8a625d: #dad6d0;
--theme-8a847c: #929ba7;
--theme-8b5b21: #e3b778;
--theme-8d8377: #bcb8b3;
--theme-8f3025: #e6c2be;
--theme-918a81: #bbb8b4;
--theme-9a938a: #bbb8b4;
--theme-9a958d: #747e8a;
--theme-9b958c: #747e8a;
--theme-9ca9df: #9ea8d1;
--theme-9d8abd: #b4aac6;
--theme-9db8a5: #b0c0b4;
--theme-9f2d20: #ff9385;
--theme-a33b2b: #f58d7f;
--theme-a83f32: #f19387;
--theme-aaa49b: #69737f;
--theme-b64132: #f39a8e;
--theme-b6d3bd: #315b3d;
--theme-b8d1bb: #315b3d;
--theme-b9d5bf: #315b3d;
--theme-bdb5a9: #beb9b1;
--theme-bf8a43: #d0bb9f;
--theme-c77b30: #d6b799;
--theme-c7c2b8: #4a5564;
--theme-c9c4ba: #bebab2;
--theme-c9d6e2: #303c47;
--theme-c9d8ca: #315b3d;
--theme-c9dfcf: #315b3d;
--theme-d5d2cb: #3b4451;
--theme-d8d1eb: #493d61;
--theme-d8d2c8: #363a46;
--theme-d8d4cc: #3b4451;
--theme-d9d4c9: #363a45;
--theme-dce2fb: #293a61;
--theme-dce8df: #203c29;
--theme-ddd8ce: #343843;
--theme-dfb7b0: #633832;
--theme-dfbf91: #654a26;
--theme-dfdcd4: #2a313b;
--theme-e2b6ae: #633832;
--theme-e2ddd3: #443d2f;
--theme-e3e1db: #303743;
--theme-e4d0af: #594328;
--theme-e5e1d9: #30343e;
--theme-e8e4dc: #40392c;
--theme-e8e5dc: #2a313b;
--theme-ece8df: #3e382b;
--theme-eeeae2: #2a3039;
--theme-eeece6: #252b34;
--theme-eef1ff: #1b2436;
--theme-eef7f0: #172a1e;
--theme-efc9c2: #633832;
--theme-f0ede6: #3b3529;
--theme-f0eee8: #151a21;
--theme-f1ecfb: #231e31;
--theme-f1f6fa: #192630;
--theme-f1f8f1: #172a1e;
--theme-f2efe8: #3a3428;
--theme-f2f0ea: #393528;
--theme-f2f7f2: #172a1e;
--theme-f3f8f4: #172a1e;
--theme-f4f1ea: #20262f;
--theme-f4f2ec: #1f2229;
--theme-f5f1ff: #231e31;
--theme-f5f7ff: #1b2436;
--theme-f6f2ec: #1f2229;
--theme-f7f3ff: #231e31;
--theme-f8f5ee: #1f2229;
--theme-f8f5ef: #1f2229;
--theme-f8f5ff: #231e31;
--theme-f8f6f1: #1f2229;
--theme-faf8f2: #1f2229;
--theme-faf8f4: #1f2229;
--theme-faf9f5: #101419;
--theme-fdf1ee: #321c1a;
--theme-fff: #1c2129;
--theme-fff1ee: #321c1a;
--theme-fff7e9: #302417;
--theme-fff8eb: #302417;
--theme-fffdf8: #191e25;
--theme-ffffff: #1c2129;
--bg-secondary: #0b0d11;
--bg-primary: #141720;
--bg-tertiary: #1c212c;
--bg-hover: #252b38;
--bg-quinary: #10131a;
--bg-card: #141720;
--bg-error-light: rgba(198, 87, 70, 0.18);
--text-primary: #f8fafc;
--text-secondary: #cbd5e1;
--text-tertiary: #94a3b8;
--text-quaternary: #64748b;
--text-muted: #94a3b8;
--border-color: #222836;
--border-secondary: #222836;
--border-primary: #2e3646;
--border-hover: #3a4356;
--primary-color: #5dd3c7;
--primary-hover: #83e0d6;
--primary-active: #4ec4b8;
--primary-contrast: #0b0d11;
--success-color: #34d399;
--viz-success: #10b981;
--warning-color: #c65746;
--error-color: #c65746;
--danger-color: #c65746;
--info-color: #f8fafc;
--amber-color: #d97706;
--amber-text: #fcd34d;
--success-badge-bg: rgba(16, 185, 129, 0.16);
--success-badge-text: #6ee7b7;
--success-badge-border: rgba(52, 211, 153, 0.38);
--failure-badge-bg: rgba(198, 87, 70, 0.18);
--failure-badge-text: #fca5a5;
--failure-badge-border: rgba(198, 87, 70, 0.35);
--count-badge-bg: rgba(248, 250, 252, 0.12);
--count-badge-text: var(--text-primary);
--ui-accent: #5dd3c7;
--ui-accent-strong: #83e0d6;
--ui-accent-contrast: #0b0d11;
--ui-accent-soft: rgba(45, 169, 158, 0.2);
--ui-focus-ring: rgba(93, 211, 199, 0.28);
--ui-accent-shadow: rgba(0, 0, 0, 0.22);
--ui-panel-shadow: 0 1px 2px rgba(0, 0, 0, 0.28), 0 12px 32px -10px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.28);
--theme-2d2a26: #f8fafc;
--theme-2f2c28: #ece9e2;
--theme-2f6b3a: #34d399;
--theme-2f6b3f: #34d399;
--theme-342f2a: #ece9e2;
--theme-344ca1: #9c958d;
--theme-39352f: #dfdcd4;
--theme-3f3550: #c9c3bb;
--theme-3f6f98: #9c958d;
--theme-42637f: #9c958d;
--theme-433f39: #d3cfc7;
--theme-4b4742: #c9c3bb;
--theme-4d78a8: #9c958d;
--theme-4f4a43: #cbd5e1;
--theme-4f825c: #34d399;
--theme-554075: #b0aaa2;
--theme-59544d: #b0aaa2;
--theme-5d9a6c: #34d399;
--theme-5f5a53: #94a3b8;
--theme-654f91: #b0aaa2;
--theme-655f57: #a29c95;
--theme-66709a: #c9c3bb;
--theme-6684a1: #c9c3bb;
--theme-6b655d: #c9c3bb;
--theme-6d6760: #cbd5e1;
--theme-6f6961: #9c958d;
--theme-716b61: #94a3b8;
--theme-777067: #9c958d;
--theme-7a746c: #94a3b8;
--theme-805a24: #fcd34d;
--theme-8069ad: #8a847c;
--theme-817b72: #8a847c;
--theme-817b73: #8a847c;
--theme-866b4d: #8a847c;
--theme-8a625d: #8a847c;
--theme-8a847c: #64748b;
--theme-8b5b21: #fcd34d;
--theme-8d8377: #6d6760;
--theme-8f3025: #c65746;
--theme-918a81: #6d6760;
--theme-9a938a: #5f5a53;
--theme-9a958d: #334155;
--theme-9b958c: #334155;
--theme-9ca9df: #8b8680;
--theme-9d8abd: #8b8680;
--theme-9db8a5: #34d399;
--theme-9f2d20: #c65746;
--theme-a33b2b: #c65746;
--theme-a83f32: #c65746;
--theme-aaa49b: #334155;
--theme-b64132: #c65746;
--theme-b6d3bd: rgba(16, 185, 129, 0.3);
--theme-b8d1bb: rgba(16, 185, 129, 0.3);
--theme-b9d5bf: rgba(16, 185, 129, 0.3);
--theme-bd5e31: #c65746;
--theme-bdb5a9: #3a3530;
--theme-bf8a43: #d97706;
--theme-c77b30: #d97706;
--theme-c7c2b8: #222836;
--theme-c9c4ba: #222836;
--theme-c9d6e2: #222836;
--theme-c9d8ca: rgba(16, 185, 129, 0.3);
--theme-c9dfcf: rgba(16, 185, 129, 0.3);
--theme-d3d0c9: #2e3646;
--theme-d5d2cb: #2e3646;
--theme-d7a447: #d97706;
--theme-d8d1eb: #3a3530;
--theme-d8d2c8: #3a3530;
--theme-d8d4cc: #222836;
--theme-d9d4c9: #1c212c;
--theme-dce2fb: rgba(139, 134, 128, 0.35);
--theme-dce8df: rgba(16, 185, 129, 0.25);
--theme-ddd8ce: #2e2a26;
--theme-dfb7b0: rgba(198, 87, 70, 0.35);
--theme-dfbf91: rgba(217, 119, 6, 0.35);
--theme-dfdcd4: #1c212c;
--theme-e2b6ae: rgba(198, 87, 70, 0.35);
--theme-e2ddd3: #2e2a26;
--theme-e3ded5: #3a3530;
--theme-e3e1db: #222836;
--theme-e4d0af: #3a3530;
--theme-e5e1d9: #2e2a26;
--theme-e8e4dc: #1c212c;
--theme-e8e5dc: #1c212c;
--theme-ece8df: #1c212c;
--theme-eeeae2: #1c212c;
--theme-eeece6: #1c212c;
--theme-eef1ff: rgba(139, 134, 128, 0.15);
--theme-eef7f0: rgba(16, 185, 129, 0.12);
--theme-efc9c2: rgba(198, 87, 70, 0.3);
--theme-f0ede6: #12151c;
--theme-f0eee8: #141720;
--theme-f1ecfb: #1c212c;
--theme-f1f6fa: #1c212c;
--theme-f1f8f1: rgba(16, 185, 129, 0.1);
--theme-f2efe8: #141720;
--theme-f2f0ea: #141720;
--theme-f2f7f2: rgba(16, 185, 129, 0.1);
--theme-f3f8f4: rgba(16, 185, 129, 0.1);
--theme-f4f1ea: #10131a;
--theme-f4f2ec: #10131a;
--theme-f5f1ff: #10131a;
--theme-f5f7ff: #10131a;
--theme-f6f2ec: #10131a;
--theme-f7f3ff: #10131a;
--theme-f8f5ee: #0f1218;
--theme-f8f5ef: #0f1218;
--theme-f8f5ff: #0f1218;
--theme-f8f6f1: #0f1218;
--theme-faf8f2: #0f1218;
--theme-faf8f4: #0f1218;
--theme-faf9f5: #0b0d11;
--theme-fdf1ee: rgba(198, 87, 70, 0.1);
--theme-fff: #141720;
--theme-fff1ee: rgba(198, 87, 70, 0.16);
--theme-fff7e9: rgba(217, 119, 6, 0.14);
--theme-fff8eb: rgba(217, 119, 6, 0.14);
--theme-fffdf8: #141720;
--theme-ffffff: #141720;
}
/*
@@ -296,68 +402,6 @@
--theme-ffffff: #ffffff;
}
:root[data-theme='dark'] {
--ui-accent: #5dd3c7;
--ui-accent-strong: #83e0d6;
--ui-accent-soft: rgba(45, 169, 158, 0.2);
--ui-focus-ring: rgba(93, 211, 199, 0.36);
--ui-accent-shadow: rgba(0, 0, 0, 0.22);
--ui-panel-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
--theme-2d2a26: #edf2ee;
--theme-342f2a: #e4eae5;
--theme-39352f: #dce3dd;
--theme-433f39: #d5ddd6;
--theme-4f4a43: #ccd5ce;
--theme-5f5a53: #c0cac2;
--theme-655f57: #b8c2ba;
--theme-6d6760: #b0bbb2;
--theme-6f6961: #adb8af;
--theme-716b61: #aab5ac;
--theme-777067: #a7b2a9;
--theme-7a746c: #9eaaa1;
--theme-817b73: #96a299;
--theme-8a847c: #8d998f;
--theme-8d8377: #8a968d;
--theme-918a81: #849087;
--theme-9a938a: #7e8a81;
--theme-9a958d: #768279;
--theme-9b958c: #78847b;
--theme-aaa49b: #6f7b72;
--theme-bdb5a9: #59655c;
--theme-c7c2b8: #4b584e;
--theme-c9c4ba: #465349;
--theme-d5d2cb: #3a473d;
--theme-d8d2c8: #354137;
--theme-d8d4cc: #3b483e;
--theme-d9d4c9: #344036;
--theme-ddd8ce: #303b32;
--theme-dfdcd4: #29352b;
--theme-e2ddd3: #354238;
--theme-e3e1db: #2f3b32;
--theme-e5e1d9: #2d3830;
--theme-e8e4dc: #28332b;
--theme-e8e5dc: #263129;
--theme-ece8df: #232d26;
--theme-eeeae2: #29342c;
--theme-eeece6: #242e27;
--theme-f0ede6: #222c25;
--theme-f0eee8: #171c18;
--theme-f2efe8: #202922;
--theme-f2f0ea: #212a23;
--theme-f4f1ea: #1e2721;
--theme-f4f2ec: #1d251f;
--theme-f6f2ec: #19211b;
--theme-f8f5ee: #1c231e;
--theme-f8f5ef: #1c231e;
--theme-f8f6f1: #1c231e;
--theme-faf8f2: #1a211c;
--theme-faf8f4: #1a211c;
--theme-faf9f5: #111412;
--theme-fff: #1b211c;
--theme-fffdf8: #1a201b;
--theme-ffffff: #1b211c;
}
:root {
/* Shared typography scale for captions, controls, headings, and metrics. */
--font-size-caption: 12px;
@@ -446,10 +490,10 @@ textarea:focus-visible {
display: flex;
flex-direction: column;
height: 100vh;
padding: 20px 16px;
border-right: 1px solid var(--theme-e3e1db);
background: var(--theme-f0eee8);
color: var(--theme-2d2a26);
padding: 20px 14px;
border-right: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
}
.sidebar-brand {
@@ -507,7 +551,7 @@ textarea:focus-visible {
.nav-section button {
display: grid;
grid-template-columns: 19px minmax(0, 1fr) 10px;
grid-template-columns: 19px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
@@ -527,6 +571,11 @@ textarea:focus-visible {
color: var(--theme-7a746c);
}
.nav-lock-icon {
color: var(--text-tertiary);
opacity: 0.85;
}
.nav-section button span {
min-width: 0;
overflow: hidden;
@@ -604,26 +653,28 @@ textarea:focus-visible {
.sidebar-easy-entry {
display: block;
width: 100%;
min-height: 40px;
min-height: 38px;
padding: 0 10px;
border: 1px solid var(--theme-d8d4cc);
border-radius: 8px;
background: var(--theme-fffdf8);
color: var(--theme-4f4a43);
border: 1px dashed var(--border-primary);
border-radius: 9px;
background: transparent;
color: var(--text-secondary);
text-align: center;
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
}
.sidebar-easy-entry:hover {
border-color: color-mix(in srgb, var(--ui-accent) 36%, var(--theme-d8d4cc));
background: var(--theme-e8e5dc);
color: var(--theme-2d2a26);
border-style: solid;
border-color: var(--border-hover);
background: var(--bg-tertiary);
color: var(--text-primary);
}
.sidebar-easy-entry.active {
border-color: color-mix(in srgb, var(--ui-accent) 42%, var(--theme-d8d4cc));
background: var(--ui-accent-soft);
color: var(--ui-accent-strong);
border-style: solid;
border-color: var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-primary);
}
.sidebar-easy-entry span {
@@ -641,9 +692,9 @@ textarea:focus-visible {
gap: 3px;
width: 100%;
padding: 3px;
border: 1px solid var(--theme-d8d4cc);
border-radius: 8px;
background: var(--theme-fffdf8);
border: 1px solid var(--border-color);
border-radius: 9px;
background: var(--bg-tertiary);
}
.sidebar-theme-selector button {
@@ -666,9 +717,9 @@ textarea:focus-visible {
}
.sidebar-theme-selector button.active {
background: var(--theme-dfdcd4);
box-shadow: inset 0 0 0 1px var(--theme-d5d2cb);
color: var(--theme-2d2a26);
background: var(--bg-primary);
box-shadow: var(--shadow-sm);
color: var(--text-primary);
}
.sidebar-language {
@@ -682,12 +733,12 @@ textarea:focus-visible {
align-items: center;
gap: 8px;
width: 100%;
min-height: 40px;
min-height: 38px;
padding: 0 10px;
border: 1px solid var(--theme-d8d4cc);
border-radius: 8px;
background: var(--theme-fffdf8);
color: var(--theme-716b61);
border: 1px solid var(--border-color);
border-radius: 9px;
background: var(--bg-primary);
color: var(--text-secondary);
text-align: left;
}
@@ -781,10 +832,10 @@ textarea:focus-visible {
width: 100%;
min-height: 38px;
padding: 0 10px;
border: 1px solid var(--theme-d8d4cc);
border-radius: 7px;
background: var(--theme-fffdf8);
color: var(--theme-4f4a43);
border: 1px solid var(--border-color);
border-radius: 9px;
background: var(--bg-primary);
color: var(--text-secondary);
text-align: left;
}
@@ -2455,6 +2506,13 @@ textarea:focus-visible {
opacity: 0.5;
}
.icon-button.copied,
.icon-button.quiet.copied {
border-color: var(--success-badge-border) !important;
background: var(--success-badge-bg) !important;
color: var(--success-color) !important;
}
.config-key-list {
min-height: 0;
overflow-x: hidden;
@@ -3197,7 +3255,7 @@ textarea:focus-visible {
.management-header {
display: flex;
align-items: end;
align-items: center;
justify-content: space-between;
gap: 18px;
min-width: 0;
@@ -4206,22 +4264,22 @@ textarea:focus-visible {
grid-template-columns: 30px minmax(0, 1fr) minmax(24px, auto);
align-items: center;
gap: 8px;
height: 54px;
height: 48px;
min-width: 0;
padding: 0 8px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
border-radius: 10px;
background: transparent;
color: var(--theme-2d2a26);
color: var(--text-primary);
}
.provider-category-panel button:hover {
background: var(--theme-f4f1ea);
background: var(--bg-tertiary);
}
.provider-category-panel button.active {
border-color: var(--theme-d5d2cb);
background: var(--theme-f0eee8);
border-color: transparent;
background: var(--bg-tertiary);
}
.provider-category-panel .provider-logo {
@@ -7770,11 +7828,11 @@ textarea:focus-visible {
/* Tab Switcher */
.usage-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--theme-e3e1db);
border-radius: 8px;
background: var(--theme-eeece6);
gap: 3px;
padding: 3px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-tertiary);
width: fit-content;
}
@@ -7797,24 +7855,24 @@ textarea:focus-visible {
}
.usage-tabs button:hover:not(.active) {
color: var(--theme-2d2a26);
background: rgba(255, 255, 255, 0.35);
color: var(--text-primary);
background: color-mix(in srgb, var(--bg-primary) 70%, transparent);
}
.usage-tabs button.active {
background: var(--theme-fffdf8);
color: var(--theme-2d2a26);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
background: var(--bg-primary);
color: var(--text-primary);
box-shadow: var(--shadow-sm);
}
/* Filter Toolbar */
.usage-filter-panel {
display: grid;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--theme-e3e1db);
border-radius: 9px;
background: var(--theme-fffdf8);
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-primary);
}
.usage-filter-row {
@@ -8082,27 +8140,6 @@ textarea:focus-visible {
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;
@@ -8137,7 +8174,7 @@ textarea:focus-visible {
min-height: 0;
height: 100%;
grid-template-columns: 36px minmax(0, 1fr);
grid-template-rows: minmax(140px, 1fr) 18px;
grid-template-rows: minmax(140px, 1fr) 28px;
column-gap: 8px;
row-gap: 4px;
}
@@ -8222,7 +8259,7 @@ textarea:focus-visible {
position: relative;
grid-column: 2;
grid-row: 2;
height: 18px;
height: 28px;
}
.usage-trend-x-axis span {
@@ -8246,6 +8283,10 @@ textarea:focus-visible {
transform: translateX(-100%);
}
.usage-trend-x-axis span:nth-child(even) {
top: 14px;
}
.usage-trend-tooltip {
position: absolute;
top: 6px;
@@ -8316,22 +8357,6 @@ textarea:focus-visible {
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;
+1 -1
View File
@@ -9,7 +9,7 @@ export type { AppTheme, ThemePreference } from './themeController';
const STORAGE_KEY = 'easy-cli-proxy-api.theme';
const WINDOW_BACKGROUND: Record<AppTheme, string> = {
light: '#f6f7f5',
dark: '#111412',
dark: '#0b0d11',
};
export function detectThemePreference(): ThemePreference {
+31 -21
View File
@@ -39,13 +39,16 @@ describe('usage trend helpers', () => {
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(date?.getMinutes()).toBe(0);
expect(formatLocalHourKey(date as Date)).toBe('2026-09-14-15-00');
expect(parseLocalHourKey('2026-09-14-15-30')?.getMinutes()).toBe(30);
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(hourStart, new Date(2026, 8, 15, 12, 0, 0))).toBe('30m');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 4, 0, 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');
@@ -65,17 +68,21 @@ describe('usage trend helpers', () => {
{ start: start.toISOString(), end: end.toISOString() },
);
expect(series.bucket).toBe('hour');
expect(series.bucket).toBe('30m');
expect(series.points.map((item) => item.hour)).toEqual([
'2026-09-14-10',
'2026-09-14-11',
'2026-09-14-12',
'2026-09-14-13',
'2026-09-14-10-00',
'2026-09-14-10-30',
'2026-09-14-11-00',
'2026-09-14-11-30',
'2026-09-14-12-00',
'2026-09-14-12-30',
'2026-09-14-13-00',
'2026-09-14-13-30',
]);
expect(series.points[1]).toMatchObject({ requests: 0, tokens: 0 });
expect(series.points[2]).toMatchObject({ requests: 1, failure: 1, tokens: 10 });
expect(series.points[4]).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?.hour).toBe('2026-09-14-10-00');
expect(series.peak?.tokens).toBe(20);
});
@@ -87,15 +94,19 @@ describe('usage trend helpers', () => {
{ start: start.toISOString(), end: end.toISOString() },
);
expect(series.bucket).toBe('hour');
expect(series.bucket).toBe('30m');
expect(series.points.map((item) => item.hour)).toEqual([
'2026-09-14-08',
'2026-09-14-09',
'2026-09-14-10',
'2026-09-14-11',
'2026-09-14-08-00',
'2026-09-14-08-30',
'2026-09-14-09-00',
'2026-09-14-09-30',
'2026-09-14-10-00',
'2026-09-14-10-30',
'2026-09-14-11-00',
'2026-09-14-11-30',
]);
expect(series.points[0]).toMatchObject({ requests: 0, tokens: 0 });
expect(series.points[2]).toMatchObject({ requests: 2, tokens: 20 });
expect(series.points[4]).toMatchObject({ requests: 2, tokens: 20 });
});
test('aggregates sparse hours into 3-hour buckets', () => {
@@ -175,8 +186,6 @@ describe('usage trend helpers', () => {
'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 });
@@ -207,10 +216,11 @@ describe('usage trend helpers', () => {
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);
expect(trendAxisTicks(20)).toEqual([0, 5, 10, 15, 20]);
expect(trendAxisTicks(5)).toEqual([0, 1, 2, 3, 4, 5]);
expect(selectTrendAxisLabels(24, 8)[0]).toBe(0);
expect(selectTrendAxisLabels(24, 8).at(-1)).toBe(23);
expect(selectTrendAxisLabels(24, 8).length).toBeGreaterThanOrEqual(7);
const hourPoint = {
...point('2026-09-14-15', 1, 1),