feat(usage): rebuild token trend chart and fill usage page height
This commit is contained in:
1 parent
67484ac423
commit
accb93c75c
8 files changed
+1622
-119
No files matched your search
+63
-12
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user