mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 11:10:49 +08:00
feat(usage): add time range filter for stats and charts
This commit is contained in:
@@ -9,6 +9,7 @@ export interface UseChartDataOptions {
|
|||||||
chartLines: string[];
|
chartLines: string[];
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
hourWindowHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseChartDataReturn {
|
export interface UseChartDataReturn {
|
||||||
@@ -26,20 +27,21 @@ export function useChartData({
|
|||||||
usage,
|
usage,
|
||||||
chartLines,
|
chartLines,
|
||||||
isDark,
|
isDark,
|
||||||
isMobile
|
isMobile,
|
||||||
|
hourWindowHours
|
||||||
}: UseChartDataOptions): UseChartDataReturn {
|
}: UseChartDataOptions): UseChartDataReturn {
|
||||||
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
||||||
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
||||||
|
|
||||||
const requestsChartData = useMemo(() => {
|
const requestsChartData = useMemo(() => {
|
||||||
if (!usage) return { labels: [], datasets: [] };
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
|
||||||
}, [usage, requestsPeriod, chartLines]);
|
}, [usage, requestsPeriod, chartLines, hourWindowHours]);
|
||||||
|
|
||||||
const tokensChartData = useMemo(() => {
|
const tokensChartData = useMemo(() => {
|
||||||
if (!usage) return { labels: [], datasets: [] };
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
|
||||||
}, [usage, tokensPeriod, chartLines]);
|
}, [usage, tokensPeriod, chartLines, hourWindowHours]);
|
||||||
|
|
||||||
const requestsChartOptions = useMemo(
|
const requestsChartOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -750,6 +750,11 @@
|
|||||||
"api_details": "API Details",
|
"api_details": "API Details",
|
||||||
"by_hour": "By Hour",
|
"by_hour": "By Hour",
|
||||||
"by_day": "By Day",
|
"by_day": "By Day",
|
||||||
|
"range_filter": "Time Range",
|
||||||
|
"range_all": "All Time",
|
||||||
|
"range_7h": "Last 7 Hours",
|
||||||
|
"range_24h": "Last 24 Hours",
|
||||||
|
"range_7d": "Last 7 Days",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
|
|||||||
@@ -753,6 +753,11 @@
|
|||||||
"api_details": "Детали API",
|
"api_details": "Детали API",
|
||||||
"by_hour": "По часам",
|
"by_hour": "По часам",
|
||||||
"by_day": "По дням",
|
"by_day": "По дням",
|
||||||
|
"range_filter": "Диапазон времени",
|
||||||
|
"range_all": "За всё время",
|
||||||
|
"range_7h": "Последние 7 часов",
|
||||||
|
"range_24h": "Последние 24 часа",
|
||||||
|
"range_7d": "Последние 7 дней",
|
||||||
"refresh": "Обновить",
|
"refresh": "Обновить",
|
||||||
"export": "Экспорт",
|
"export": "Экспорт",
|
||||||
"import": "Импорт",
|
"import": "Импорт",
|
||||||
|
|||||||
@@ -750,6 +750,11 @@
|
|||||||
"api_details": "API 详细统计",
|
"api_details": "API 详细统计",
|
||||||
"by_hour": "按小时",
|
"by_hour": "按小时",
|
||||||
"by_day": "按天",
|
"by_day": "按天",
|
||||||
|
"range_filter": "时间范围",
|
||||||
|
"range_all": "全部时间",
|
||||||
|
"range_7h": "最近7小时",
|
||||||
|
"range_24h": "最近24小时",
|
||||||
|
"range_7d": "最近7天",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"export": "导出数据",
|
"export": "导出数据",
|
||||||
"import": "导入数据",
|
"import": "导入数据",
|
||||||
|
|||||||
@@ -25,6 +25,59 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeRangeGroup {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeRangeLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeRangeSelectWrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select.timeRangeSelect {
|
||||||
|
min-width: 164px;
|
||||||
|
height: 40px;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-right: 34px;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeRangeSelectIcon {
|
||||||
|
position: absolute;
|
||||||
|
right: 11px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { IconChevronDown } from '@/components/ui/icons';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
@@ -27,7 +28,13 @@ import {
|
|||||||
useSparklines,
|
useSparklines,
|
||||||
useChartData
|
useChartData
|
||||||
} from '@/components/usage';
|
} from '@/components/usage';
|
||||||
import { getModelNamesFromUsage, getApiStats, getModelStats } from '@/utils/usage';
|
import {
|
||||||
|
getModelNamesFromUsage,
|
||||||
|
getApiStats,
|
||||||
|
getModelStats,
|
||||||
|
filterUsageByTimeRange,
|
||||||
|
type UsageTimeRange
|
||||||
|
} from '@/utils/usage';
|
||||||
import styles from './UsagePage.module.scss';
|
import styles from './UsagePage.module.scss';
|
||||||
|
|
||||||
// Register Chart.js components
|
// Register Chart.js components
|
||||||
@@ -43,8 +50,18 @@ ChartJS.register(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1';
|
const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1';
|
||||||
|
const TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-time-range-v1';
|
||||||
const DEFAULT_CHART_LINES = ['all'];
|
const DEFAULT_CHART_LINES = ['all'];
|
||||||
|
const DEFAULT_TIME_RANGE: UsageTimeRange = '24h';
|
||||||
const MAX_CHART_LINES = 9;
|
const MAX_CHART_LINES = 9;
|
||||||
|
const HOUR_WINDOW_BY_TIME_RANGE: Record<Exclude<UsageTimeRange, 'all'>, number> = {
|
||||||
|
'7h': 7,
|
||||||
|
'24h': 24,
|
||||||
|
'7d': 7 * 24
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUsageTimeRange = (value: unknown): value is UsageTimeRange =>
|
||||||
|
value === '7h' || value === '24h' || value === '7d' || value === 'all';
|
||||||
|
|
||||||
const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => {
|
const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
@@ -75,6 +92,18 @@ const loadChartLines = (): string[] => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadTimeRange = (): UsageTimeRange => {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return DEFAULT_TIME_RANGE;
|
||||||
|
}
|
||||||
|
const raw = localStorage.getItem(TIME_RANGE_STORAGE_KEY);
|
||||||
|
return isUsageTimeRange(raw) ? raw : DEFAULT_TIME_RANGE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_TIME_RANGE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function UsagePage() {
|
export function UsagePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
@@ -101,6 +130,14 @@ export function UsagePage() {
|
|||||||
|
|
||||||
// Chart lines state
|
// Chart lines state
|
||||||
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
|
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
|
||||||
|
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
|
||||||
|
|
||||||
|
const filteredUsage = useMemo(
|
||||||
|
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
|
||||||
|
[usage, timeRange]
|
||||||
|
);
|
||||||
|
const hourWindowHours =
|
||||||
|
timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
|
||||||
|
|
||||||
const handleChartLinesChange = useCallback((lines: string[]) => {
|
const handleChartLinesChange = useCallback((lines: string[]) => {
|
||||||
setChartLines(normalizeChartLines(lines));
|
setChartLines(normalizeChartLines(lines));
|
||||||
@@ -117,6 +154,17 @@ export function UsagePage() {
|
|||||||
}
|
}
|
||||||
}, [chartLines]);
|
}, [chartLines]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
// Sparklines hook
|
// Sparklines hook
|
||||||
const {
|
const {
|
||||||
requestsSparkline,
|
requestsSparkline,
|
||||||
@@ -124,7 +172,7 @@ export function UsagePage() {
|
|||||||
rpmSparkline,
|
rpmSparkline,
|
||||||
tpmSparkline,
|
tpmSparkline,
|
||||||
costSparkline
|
costSparkline
|
||||||
} = useSparklines({ usage, loading });
|
} = useSparklines({ usage: filteredUsage, loading });
|
||||||
|
|
||||||
// Chart data hook
|
// Chart data hook
|
||||||
const {
|
const {
|
||||||
@@ -136,12 +184,18 @@ export function UsagePage() {
|
|||||||
tokensChartData,
|
tokensChartData,
|
||||||
requestsChartOptions,
|
requestsChartOptions,
|
||||||
tokensChartOptions
|
tokensChartOptions
|
||||||
} = useChartData({ usage, chartLines, isDark, isMobile });
|
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
|
||||||
|
|
||||||
// Derived data
|
// Derived data
|
||||||
const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]);
|
const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]);
|
||||||
const apiStats = useMemo(() => getApiStats(usage, modelPrices), [usage, modelPrices]);
|
const apiStats = useMemo(
|
||||||
const modelStats = useMemo(() => getModelStats(usage, modelPrices), [usage, modelPrices]);
|
() => getApiStats(filteredUsage, modelPrices),
|
||||||
|
[filteredUsage, modelPrices]
|
||||||
|
);
|
||||||
|
const modelStats = useMemo(
|
||||||
|
() => getModelStats(filteredUsage, modelPrices),
|
||||||
|
[filteredUsage, modelPrices]
|
||||||
|
);
|
||||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,6 +212,24 @@ export function UsagePage() {
|
|||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
|
<div className={styles.timeRangeGroup}>
|
||||||
|
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
|
||||||
|
<div className={styles.timeRangeSelectWrap}>
|
||||||
|
<select
|
||||||
|
value={timeRange}
|
||||||
|
onChange={(event) => setTimeRange(event.target.value as UsageTimeRange)}
|
||||||
|
className={`${styles.select} ${styles.timeRangeSelect}`}
|
||||||
|
>
|
||||||
|
<option value="all">{t('usage_stats.range_all')}</option>
|
||||||
|
<option value="7h">{t('usage_stats.range_7h')}</option>
|
||||||
|
<option value="24h">{t('usage_stats.range_24h')}</option>
|
||||||
|
<option value="7d">{t('usage_stats.range_7d')}</option>
|
||||||
|
</select>
|
||||||
|
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
|
||||||
|
<IconChevronDown size={14} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -198,7 +270,7 @@ export function UsagePage() {
|
|||||||
|
|
||||||
{/* Stats Overview Cards */}
|
{/* Stats Overview Cards */}
|
||||||
<StatCards
|
<StatCards
|
||||||
usage={usage}
|
usage={filteredUsage}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
modelPrices={modelPrices}
|
modelPrices={modelPrices}
|
||||||
sparklines={{
|
sparklines={{
|
||||||
|
|||||||
@@ -61,8 +61,15 @@ export interface ApiStats {
|
|||||||
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
|
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
|
||||||
|
|
||||||
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
||||||
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
||||||
|
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
|
||||||
|
'7h': 7 * 60 * 60 * 1000,
|
||||||
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
|
'7d': 7 * 24 * 60 * 60 * 1000
|
||||||
|
};
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
@@ -73,6 +80,140 @@ const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
|
|||||||
return isRecord(apisRaw) ? apisRaw : null;
|
return isRecord(apisRaw) ? apisRaw : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface UsageSummary {
|
||||||
|
totalRequests: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
totalTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUsageSummary = (): UsageSummary => ({
|
||||||
|
totalRequests: 0,
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
totalTokens: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const toUsageSummaryFields = (summary: UsageSummary) => ({
|
||||||
|
total_requests: summary.totalRequests,
|
||||||
|
success_count: summary.successCount,
|
||||||
|
failure_count: summary.failureCount,
|
||||||
|
total_tokens: summary.totalTokens
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDetailWithinWindow = (detail: unknown, windowStart: number, nowMs: number): detail is Record<string, unknown> => {
|
||||||
|
if (!isRecord(detail) || typeof detail.timestamp !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(detail.timestamp);
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timestamp >= windowStart && timestamp <= nowMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSummaryFromDetails = (summary: UsageSummary, details: unknown[]) => {
|
||||||
|
details.forEach((detail) => {
|
||||||
|
const detailRecord = isRecord(detail) ? detail : null;
|
||||||
|
if (!detailRecord) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.totalRequests += 1;
|
||||||
|
if (detailRecord.failed === true) {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
} else {
|
||||||
|
summary.successCount += 1;
|
||||||
|
}
|
||||||
|
summary.totalTokens += extractTotalTokens(detailRecord);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
|
||||||
|
if (range === 'all') {
|
||||||
|
return usageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageRecord = isRecord(usageData) ? usageData : null;
|
||||||
|
const apis = getApisRecord(usageData);
|
||||||
|
if (!usageRecord || !apis) {
|
||||||
|
return usageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeMs = USAGE_TIME_RANGE_MS[range];
|
||||||
|
if (!Number.isFinite(rangeMs) || rangeMs <= 0) {
|
||||||
|
return usageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowStart = nowMs - rangeMs;
|
||||||
|
const filteredApis: Record<string, unknown> = {};
|
||||||
|
const totalSummary = createUsageSummary();
|
||||||
|
|
||||||
|
Object.entries(apis).forEach(([apiName, apiEntry]) => {
|
||||||
|
if (!isRecord(apiEntry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = isRecord(apiEntry.models) ? apiEntry.models : null;
|
||||||
|
if (!models) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredModels: Record<string, unknown> = {};
|
||||||
|
const apiSummary = createUsageSummary();
|
||||||
|
|
||||||
|
Object.entries(models).forEach(([modelName, modelEntry]) => {
|
||||||
|
if (!isRecord(modelEntry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsRaw = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
||||||
|
const filteredDetails = detailsRaw.filter((detail) =>
|
||||||
|
isDetailWithinWindow(detail, windowStart, nowMs)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filteredDetails.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelSummary = createUsageSummary();
|
||||||
|
updateSummaryFromDetails(modelSummary, filteredDetails);
|
||||||
|
|
||||||
|
filteredModels[modelName] = {
|
||||||
|
...modelEntry,
|
||||||
|
...toUsageSummaryFields(modelSummary),
|
||||||
|
details: filteredDetails
|
||||||
|
};
|
||||||
|
|
||||||
|
apiSummary.totalRequests += modelSummary.totalRequests;
|
||||||
|
apiSummary.successCount += modelSummary.successCount;
|
||||||
|
apiSummary.failureCount += modelSummary.failureCount;
|
||||||
|
apiSummary.totalTokens += modelSummary.totalTokens;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(filteredModels).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredApis[apiName] = {
|
||||||
|
...apiEntry,
|
||||||
|
...toUsageSummaryFields(apiSummary),
|
||||||
|
models: filteredModels
|
||||||
|
};
|
||||||
|
|
||||||
|
totalSummary.totalRequests += apiSummary.totalRequests;
|
||||||
|
totalSummary.successCount += apiSummary.successCount;
|
||||||
|
totalSummary.failureCount += apiSummary.failureCount;
|
||||||
|
totalSummary.totalTokens += apiSummary.totalTokens;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...usageRecord,
|
||||||
|
...toUsageSummaryFields(totalSummary),
|
||||||
|
apis: filteredApis
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeAuthIndex = (value: unknown) => {
|
const normalizeAuthIndex = (value: unknown) => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return value.toString();
|
return value.toString();
|
||||||
@@ -735,23 +876,28 @@ export function formatDayLabel(date: Date): string {
|
|||||||
*/
|
*/
|
||||||
export function buildHourlySeriesByModel(
|
export function buildHourlySeriesByModel(
|
||||||
usageData: unknown,
|
usageData: unknown,
|
||||||
metric: 'requests' | 'tokens' = 'requests'
|
metric: 'requests' | 'tokens' = 'requests',
|
||||||
|
hourWindow: number = 24
|
||||||
): {
|
): {
|
||||||
labels: string[];
|
labels: string[];
|
||||||
dataByModel: Map<string, number[]>;
|
dataByModel: Map<string, number[]>;
|
||||||
hasData: boolean;
|
hasData: boolean;
|
||||||
} {
|
} {
|
||||||
const hourMs = 60 * 60 * 1000;
|
const hourMs = 60 * 60 * 1000;
|
||||||
|
const resolvedHourWindow =
|
||||||
|
Number.isFinite(hourWindow) && hourWindow > 0
|
||||||
|
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
|
||||||
|
: 24;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentHour = new Date(now);
|
const currentHour = new Date(now);
|
||||||
currentHour.setMinutes(0, 0, 0);
|
currentHour.setMinutes(0, 0, 0);
|
||||||
|
|
||||||
const earliestBucket = new Date(currentHour);
|
const earliestBucket = new Date(currentHour);
|
||||||
earliestBucket.setHours(earliestBucket.getHours() - 23);
|
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
|
||||||
const earliestTime = earliestBucket.getTime();
|
const earliestTime = earliestBucket.getTime();
|
||||||
|
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
for (let i = 0; i < 24; i++) {
|
for (let i = 0; i < resolvedHourWindow; i++) {
|
||||||
const bucketStart = earliestTime + i * hourMs;
|
const bucketStart = earliestTime + i * hourMs;
|
||||||
labels.push(formatHourLabel(new Date(bucketStart)));
|
labels.push(formatHourLabel(new Date(bucketStart)));
|
||||||
}
|
}
|
||||||
@@ -927,10 +1073,11 @@ export function buildChartData(
|
|||||||
usageData: unknown,
|
usageData: unknown,
|
||||||
period: 'hour' | 'day' = 'day',
|
period: 'hour' | 'day' = 'day',
|
||||||
metric: 'requests' | 'tokens' = 'requests',
|
metric: 'requests' | 'tokens' = 'requests',
|
||||||
selectedModels: string[] = []
|
selectedModels: string[] = [],
|
||||||
|
options: { hourWindowHours?: number } = {}
|
||||||
): ChartData {
|
): ChartData {
|
||||||
const baseSeries = period === 'hour'
|
const baseSeries = period === 'hour'
|
||||||
? buildHourlySeriesByModel(usageData, metric)
|
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
|
||||||
: buildDailySeriesByModel(usageData, metric);
|
: buildDailySeriesByModel(usageData, metric);
|
||||||
|
|
||||||
const { labels, dataByModel } = baseSeries;
|
const { labels, dataByModel } = baseSeries;
|
||||||
|
|||||||
Reference in New Issue
Block a user