From accb93c75ca7b5b034f00b6d8e464954e4cc4fda Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 14 Sep 2026 20:36:26 +0800 Subject: [PATCH] feat(usage): rebuild token trend chart and fill usage page height --- src-tauri/src/usage.rs | 75 ++++- src/i18n/ja.ts | 14 +- src/i18n/locales/en.ts | 14 +- src/i18n/locales/zh-CN.ts | 14 +- src/pages/UsageRecordsPage.tsx | 465 ++++++++++++++++++++++++---- src/services/usageTrend.ts | 533 +++++++++++++++++++++++++++++++++ src/styles.css | 400 ++++++++++++++++++++++--- tests/usageTrend.test.ts | 226 ++++++++++++++ 8 files changed, 1622 insertions(+), 119 deletions(-) create mode 100644 src/services/usageTrend.ts create mode 100644 tests/usageTrend.test.ts diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 0468a6e..085fd17 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -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, } #[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::, _>>() .map_err(|error| format!("读取 SQLite 使用趋势失败: {error}"))?; + let mut grouped = BTreeMap::::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::>(); 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"); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3a510f1..9469939 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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': '認証キー', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 14b89f5..f84a636 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -447,8 +447,7 @@ export const en: Record = { '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 = { '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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 3cd84f0..9df994c 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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': '鉴权密钥', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index 24e9545..7b486dd 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -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(null); const [overview, setOverview] = useState(null); + const [overviewRange, setOverviewRange] = useState>({}); const [analysis, setAnalysis] = useState(emptyAnalysis); const [optionsAnalysis, setOptionsAnalysis] = useState(emptyAnalysis); const [events, setEvents] = useState(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() { ) : null} - {activeTab === 'overview' && overview ? : null} + {activeTab === 'overview' && overview ? : null} {activeTab === 'analysis' ? : null} {activeTab === 'events' && events ? ( }) { const { t } = useI18n(); const cards = [ { @@ -895,10 +903,9 @@ function OverviewView({ overview }: { overview: UsageOverview }) {
{t('usage.trend.title')} - {t('usage.trend.description')}
- {overview.timeline.length ? : } + {overview.timeline.length ? : }
@@ -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; +}) { + const { t, locale } = useI18n(); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [hiddenModels, setHiddenModels] = useState([]); - 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 ; + } + + 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) => { + 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 (
-
- - {compactNumber(totalReqs)} {t('usage.unit.requests')} - - - {compactNumber(totalTokens)} {t('usage.unit.tokens')} - -
-
- - - - - - - - - - - {areaPath ? : null} - - -
- {recent[0]?.hour ?? ''} - {recent.length > 2 ? {recent[Math.floor(recent.length / 2)]?.hour ?? ''} : null} - {recent[recent.length - 1]?.hour ?? ''} +
+
+ {series.models.map((model) => { + const isHidden = hiddenKeys.has(model.key); + return ( + + ); + })} +
+
+ + {compactNumber(series.totals.tokens)} {t('usage.unit.tokens')} + + {bucketLabel}
+ +
+ + +
+ + + {visibleModels.map((model) => ( + + + + + ))} + + + + + + + {chart.areaLayers.map((layer) => { + if (!layer.hasTokens || !layer.areaPath) return null; + return ( + + + + + ); + })} + + {chart.totalLinePath ? ( + + ) : null} + + {active && hoveredIndex != null ? ( + + + {activeLayers.map((layer) => { + const model = series.models.find((m) => m.key === layer.key); + const dotY = chart.baseY - (layer.y1 / chart.maxTokens) * chart.UH; + return ( + + ); + })} + + ) : null} + + + {active && hoveredIndex != null ? ( +
62 ? ' is-left' : ' is-right'}`} + style={{ left: `${activePercent}%` }} + > +
+ {formatTrendRangeLabel(active, locale, series.bucket)} + + {compactNumber(activeTotal)} {t('usage.trend.tooltip.tokens')} + +
+ {activeLayers.length > 0 ? ( +
+ {activeLayers.map((layer) => { + const model = series.models.find((m) => m.key === layer.key); + return ( +
+ + + {modelLabel(layer.key, model?.label ?? layer.key)} + {model?.star ? {t('usage.trend.star')} : null} + + {compactNumber(layer.tokens)} +
+ ); + })} +
+ ) : ( +
{t('usage.unit.tokens')}: 0
+ )} +
+ ) : null} +
+ + +
+ + + {srText} +
); } diff --git a/src/services/usageTrend.ts b/src/services/usageTrend.ts new file mode 100644 index 0000000..713d0f2 --- /dev/null +++ b/src/services/usageTrend.ts @@ -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; + 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, + point: Pick, +) { + 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([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, 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(); + const parsed = points + .map((point) => { + const start = parseLocalHourKey(point.hour); + if (!start) return null; + const models: Record = {}; + 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 } => point !== null) + .sort((left, right) => left.start.getTime() - right.start.getTime()); + + const totals = emptyTotals(); + const modelTotals = new Map(); + 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(); + 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((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 = 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; diff --git a/src/styles.css b/src/styles.css index a73ac30..ff9f638 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/tests/usageTrend.test.ts b/tests/usageTrend.test.ts new file mode 100644 index 0000000..78e99cc --- /dev/null +++ b/tests/usageTrend.test.ts @@ -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 => ({ + 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/); + }); +});