mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
5 Commits
@@ -82,3 +82,51 @@
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.contentWithFloatingAction {
|
||||
padding-bottom: calc(
|
||||
var(--secondary-shell-floating-action-height, 56px) + 12px + env(safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
|
||||
.floatingActionContainer {
|
||||
position: fixed;
|
||||
left: var(--content-center-x, 50%);
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.floatingActionSurface {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) {
|
||||
.floatingActionSurface {
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.floatingActionContainer {
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.floatingActionSurface {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { forwardRef, type ReactNode } from 'react';
|
||||
import { forwardRef, useLayoutEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { IconChevronLeft } from '@/components/ui/icons';
|
||||
import { usePageTransitionLayer } from './PageTransitionLayer';
|
||||
import styles from './SecondaryScreenShell.module.scss';
|
||||
|
||||
export type SecondaryScreenShellProps = {
|
||||
@@ -10,6 +12,9 @@ export type SecondaryScreenShellProps = {
|
||||
backLabel?: string;
|
||||
backAriaLabel?: string;
|
||||
rightAction?: ReactNode;
|
||||
hideTopBarBackButton?: boolean;
|
||||
hideTopBarRightAction?: boolean;
|
||||
floatingAction?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
loadingLabel?: ReactNode;
|
||||
className?: string;
|
||||
@@ -25,6 +30,9 @@ export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenSh
|
||||
backLabel = 'Back',
|
||||
backAriaLabel,
|
||||
rightAction,
|
||||
hideTopBarBackButton = false,
|
||||
hideTopBarRightAction = false,
|
||||
floatingAction,
|
||||
isLoading = false,
|
||||
loadingLabel = 'Loading...',
|
||||
className = '',
|
||||
@@ -34,45 +42,94 @@ export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenSh
|
||||
ref
|
||||
) {
|
||||
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
|
||||
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
|
||||
const contentClasses = [
|
||||
styles.content,
|
||||
floatingAction ? styles.contentWithFloatingAction : '',
|
||||
contentClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const titleTooltip = typeof title === 'string' ? title : undefined;
|
||||
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const shouldRenderFloatingAction = Boolean(floatingAction) && isCurrentLayer;
|
||||
const floatingActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldRenderFloatingAction) return;
|
||||
|
||||
const element = floatingActionRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
const height = element.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty(
|
||||
'--secondary-shell-floating-action-height',
|
||||
`${height}px`
|
||||
);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
window.addEventListener('resize', updateHeight);
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight);
|
||||
resizeObserver?.observe(element);
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateHeight);
|
||||
document.documentElement.style.removeProperty('--secondary-shell-floating-action-height');
|
||||
};
|
||||
}, [shouldRenderFloatingAction]);
|
||||
|
||||
return (
|
||||
<div className={containerClassName} ref={ref}>
|
||||
<div className={styles.topBar}>
|
||||
{onBack ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className={styles.backButton}
|
||||
aria-label={resolvedBackAriaLabel}
|
||||
>
|
||||
<span className={styles.backIcon}>
|
||||
<IconChevronLeft size={18} />
|
||||
</span>
|
||||
<span className={styles.backText}>{backLabel}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className={styles.topBarTitle} title={titleTooltip}>
|
||||
{title}
|
||||
<>
|
||||
<div className={containerClassName} ref={ref}>
|
||||
<div className={styles.topBar}>
|
||||
{onBack && !hideTopBarBackButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className={styles.backButton}
|
||||
aria-label={resolvedBackAriaLabel}
|
||||
>
|
||||
<span className={styles.backIcon}>
|
||||
<IconChevronLeft size={18} />
|
||||
</span>
|
||||
<span className={styles.backText}>{backLabel}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className={styles.topBarTitle} title={titleTooltip}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.rightSlot}>{hideTopBarRightAction ? null : rightAction}</div>
|
||||
</div>
|
||||
<div className={styles.rightSlot}>{rightAction}</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.loadingState}>
|
||||
<LoadingSpinner size={16} />
|
||||
<span>{loadingLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={contentClasses}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className={styles.loadingState}>
|
||||
<LoadingSpinner size={16} />
|
||||
<span>{loadingLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={contentClasses}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
{shouldRenderFloatingAction && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div className={styles.floatingActionContainer}>
|
||||
<div className={styles.floatingActionSurface} ref={floatingActionRef}>
|
||||
{floatingAction}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
computeKeyStats,
|
||||
collectUsageDetails,
|
||||
buildCandidateUsageSourceIds,
|
||||
formatCompactNumber,
|
||||
@@ -82,14 +81,49 @@ export function CredentialStatsCard({
|
||||
// Auth files are used purely for name resolution of unmatched source IDs.
|
||||
const rows = useMemo((): CredentialRow[] => {
|
||||
if (!usage) return [];
|
||||
const { bySource } = computeKeyStats(usage);
|
||||
const details = collectUsageDetails(usage);
|
||||
const bySource: Record<string, CredentialBucket> = {};
|
||||
const result: CredentialRow[] = [];
|
||||
const consumedSourceIds = new Set<string>();
|
||||
const authIndexToRowIndex = new Map<string, number>();
|
||||
const sourceToAuthIndex = new Map<string, string>();
|
||||
const sourceToAuthFile = new Map<string, CredentialInfo>();
|
||||
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
|
||||
|
||||
details.forEach((detail) => {
|
||||
const authIdx = normalizeAuthIndex(detail.auth_index);
|
||||
const source = detail.source;
|
||||
const isFailed = detail.failed === true;
|
||||
|
||||
if (!source) {
|
||||
if (!authIdx) return;
|
||||
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
|
||||
if (isFailed) {
|
||||
fallback.failure += 1;
|
||||
} else {
|
||||
fallback.success += 1;
|
||||
}
|
||||
fallbackByAuthIndex.set(authIdx, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
const bucket = bySource[source] ?? { success: 0, failure: 0 };
|
||||
if (isFailed) {
|
||||
bucket.failure += 1;
|
||||
} else {
|
||||
bucket.success += 1;
|
||||
}
|
||||
bySource[source] = bucket;
|
||||
|
||||
if (authIdx && !sourceToAuthIndex.has(source)) {
|
||||
sourceToAuthIndex.set(source, authIdx);
|
||||
}
|
||||
if (authIdx && !sourceToAuthFile.has(source)) {
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
if (mapped) sourceToAuthFile.set(source, mapped);
|
||||
}
|
||||
});
|
||||
|
||||
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
|
||||
const target = result[index];
|
||||
if (!target) return;
|
||||
@@ -177,33 +211,6 @@ export function CredentialStatsCard({
|
||||
}
|
||||
});
|
||||
|
||||
// Build source → auth file name mapping for remaining unmatched entries.
|
||||
// Also collect fallback stats for details without source but with auth_index.
|
||||
const sourceToAuthFile = new Map<string, CredentialInfo>();
|
||||
details.forEach((d) => {
|
||||
const authIdx = normalizeAuthIndex(d.auth_index);
|
||||
if (!d.source) {
|
||||
if (!authIdx) return;
|
||||
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
|
||||
if (d.failed === true) {
|
||||
fallback.failure += 1;
|
||||
} else {
|
||||
fallback.success += 1;
|
||||
}
|
||||
fallbackByAuthIndex.set(authIdx, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authIdx || consumedSourceIds.has(d.source)) return;
|
||||
if (!sourceToAuthIndex.has(d.source)) {
|
||||
sourceToAuthIndex.set(d.source, authIdx);
|
||||
}
|
||||
if (!sourceToAuthFile.has(d.source)) {
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
if (mapped) sourceToAuthFile.set(d.source, mapped);
|
||||
}
|
||||
});
|
||||
|
||||
// Remaining unmatched bySource entries — resolve name from auth files if possible
|
||||
Object.entries(bySource).forEach(([key, bucket]) => {
|
||||
if (consumedSourceIds.has(key)) return;
|
||||
|
||||
@@ -119,8 +119,11 @@ export function RequestEventsDetailsCard({
|
||||
|
||||
return details
|
||||
.map((detail, index) => {
|
||||
const timestamp = typeof detail.timestamp === 'string' ? detail.timestamp : '';
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const timestamp = detail.timestamp;
|
||||
const timestampMs =
|
||||
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
|
||||
? detail.__timestampMs
|
||||
: Date.parse(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
formatCompactNumber,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateTokenBreakdown,
|
||||
calculateRecentPerMinuteRates,
|
||||
calculateTotalCost,
|
||||
calculateCost,
|
||||
collectUsageDetails,
|
||||
extractTotalTokens,
|
||||
type ModelPrice
|
||||
} from '@/utils/usage';
|
||||
import { sparklineOptions } from '@/utils/usage/chartConfig';
|
||||
@@ -32,6 +32,7 @@ export interface StatCardsProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
nowMs: number;
|
||||
sparklines: {
|
||||
requests: SparklineBundle | null;
|
||||
tokens: SparklineBundle | null;
|
||||
@@ -41,16 +42,68 @@ export interface StatCardsProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
|
||||
export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||
const rateStats = usage
|
||||
? calculateRecentPerMinuteRates(30, usage)
|
||||
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
|
||||
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
const { tokenBreakdown, rateStats, totalCost } = useMemo(() => {
|
||||
const empty = {
|
||||
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
|
||||
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
|
||||
totalCost: 0
|
||||
};
|
||||
|
||||
if (!usage) return empty;
|
||||
const details = collectUsageDetails(usage);
|
||||
if (!details.length) return empty;
|
||||
|
||||
let cachedTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
const now = nowMs;
|
||||
const windowMinutes = 30;
|
||||
const windowStart = now - windowMinutes * 60 * 1000;
|
||||
let requestCount = 0;
|
||||
let tokenCount = 0;
|
||||
const hasValidNow = Number.isFinite(now) && now > 0;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const tokens = detail.tokens;
|
||||
cachedTokens += Math.max(
|
||||
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
|
||||
);
|
||||
if (typeof tokens.reasoning_tokens === 'number') {
|
||||
reasoningTokens += tokens.reasoning_tokens;
|
||||
}
|
||||
|
||||
const timestamp = detail.__timestampMs ?? 0;
|
||||
if (hasValidNow && Number.isFinite(timestamp) && timestamp >= windowStart && timestamp <= now) {
|
||||
requestCount += 1;
|
||||
tokenCount += extractTotalTokens(detail);
|
||||
}
|
||||
|
||||
if (hasPrices) {
|
||||
totalCost += calculateCost(detail, modelPrices);
|
||||
}
|
||||
});
|
||||
|
||||
const denominator = windowMinutes > 0 ? windowMinutes : 1;
|
||||
return {
|
||||
tokenBreakdown: { cachedTokens, reasoningTokens },
|
||||
rateStats: {
|
||||
rpm: requestCount / denominator,
|
||||
tpm: tokenCount / denominator,
|
||||
windowMinutes,
|
||||
requestCount,
|
||||
tokenCount
|
||||
},
|
||||
totalCost
|
||||
};
|
||||
}, [hasPrices, modelPrices, nowMs, usage]);
|
||||
|
||||
const statsCards: StatCardData[] = [
|
||||
{
|
||||
key: 'requests',
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface SparklineBundle {
|
||||
export interface UseSparklinesOptions {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
nowMs: number;
|
||||
}
|
||||
|
||||
export interface UseSparklinesReturn {
|
||||
@@ -34,42 +35,43 @@ export interface UseSparklinesReturn {
|
||||
costSparkline: SparklineBundle | null;
|
||||
}
|
||||
|
||||
export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn {
|
||||
const buildLastHourSeries = useCallback(
|
||||
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
|
||||
if (!usage) return { labels: [], data: [] };
|
||||
const details = collectUsageDetails(usage);
|
||||
if (!details.length) return { labels: [], data: [] };
|
||||
export function useSparklines({ usage, loading, nowMs }: UseSparklinesOptions): UseSparklinesReturn {
|
||||
const lastHourSeries = useMemo(() => {
|
||||
if (!usage) return { labels: [], requests: [], tokens: [] };
|
||||
if (!Number.isFinite(nowMs) || nowMs <= 0) {
|
||||
return { labels: [], requests: [], tokens: [] };
|
||||
}
|
||||
const details = collectUsageDetails(usage);
|
||||
if (!details.length) return { labels: [], requests: [], tokens: [] };
|
||||
|
||||
const windowMinutes = 60;
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMinutes * 60 * 1000;
|
||||
const buckets = new Array(windowMinutes).fill(0);
|
||||
const windowMinutes = 60;
|
||||
const now = nowMs;
|
||||
const windowStart = now - windowMinutes * 60 * 1000;
|
||||
const requestBuckets = new Array(windowMinutes).fill(0);
|
||||
const tokenBuckets = new Array(windowMinutes).fill(0);
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||||
return;
|
||||
}
|
||||
const minuteIndex = Math.min(
|
||||
windowMinutes - 1,
|
||||
Math.floor((timestamp - windowStart) / 60000)
|
||||
);
|
||||
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
|
||||
buckets[minuteIndex] += increment;
|
||||
});
|
||||
details.forEach((detail) => {
|
||||
const timestamp = detail.__timestampMs ?? 0;
|
||||
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
const minuteIndex = Math.min(
|
||||
windowMinutes - 1,
|
||||
Math.floor((timestamp - windowStart) / 60000)
|
||||
);
|
||||
requestBuckets[minuteIndex] += 1;
|
||||
tokenBuckets[minuteIndex] += extractTotalTokens(detail);
|
||||
});
|
||||
|
||||
const labels = buckets.map((_, idx) => {
|
||||
const date = new Date(windowStart + (idx + 1) * 60000);
|
||||
const h = date.getHours().toString().padStart(2, '0');
|
||||
const m = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
});
|
||||
const labels = requestBuckets.map((_, idx) => {
|
||||
const date = new Date(windowStart + (idx + 1) * 60000);
|
||||
const h = date.getHours().toString().padStart(2, '0');
|
||||
const m = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
});
|
||||
|
||||
return { labels, data: buckets };
|
||||
},
|
||||
[usage]
|
||||
);
|
||||
return { labels, requests: requestBuckets, tokens: tokenBuckets };
|
||||
}, [nowMs, usage]);
|
||||
|
||||
const buildSparkline = useCallback(
|
||||
(
|
||||
@@ -104,28 +106,53 @@ export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSpar
|
||||
);
|
||||
|
||||
const requestsSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('requests'), '#8b8680', 'rgba(139, 134, 128, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
() =>
|
||||
buildSparkline(
|
||||
{ labels: lastHourSeries.labels, data: lastHourSeries.requests },
|
||||
'#8b8680',
|
||||
'rgba(139, 134, 128, 0.18)'
|
||||
),
|
||||
[buildSparkline, lastHourSeries.labels, lastHourSeries.requests]
|
||||
);
|
||||
|
||||
const tokensSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
() =>
|
||||
buildSparkline(
|
||||
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
|
||||
'#8b5cf6',
|
||||
'rgba(139, 92, 246, 0.18)'
|
||||
),
|
||||
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
|
||||
);
|
||||
|
||||
const rpmSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
() =>
|
||||
buildSparkline(
|
||||
{ labels: lastHourSeries.labels, data: lastHourSeries.requests },
|
||||
'#22c55e',
|
||||
'rgba(34, 197, 94, 0.18)'
|
||||
),
|
||||
[buildSparkline, lastHourSeries.labels, lastHourSeries.requests]
|
||||
);
|
||||
|
||||
const tpmSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
() =>
|
||||
buildSparkline(
|
||||
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
|
||||
'#f97316',
|
||||
'rgba(249, 115, 22, 0.18)'
|
||||
),
|
||||
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
|
||||
);
|
||||
|
||||
const costSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
() =>
|
||||
buildSparkline(
|
||||
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
|
||||
'#f59e0b',
|
||||
'rgba(245, 158, 11, 0.18)'
|
||||
),
|
||||
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,14 +2,20 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconBot, IconCheck, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
||||
import {
|
||||
IconBot,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconDownload,
|
||||
IconInfo,
|
||||
IconTrash2,
|
||||
} from '@/components/ui/icons';
|
||||
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { resolveAuthProvider } from '@/utils/quota';
|
||||
import { calculateStatusBarData, normalizeAuthIndex, type KeyStats } from '@/utils/usage';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import {
|
||||
AUTH_FILE_REFRESH_WARNING_MS,
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
formatModified,
|
||||
getTypeColor,
|
||||
@@ -17,26 +23,13 @@ import {
|
||||
isRuntimeOnlyAuthFile,
|
||||
resolveAuthFileStats,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme
|
||||
type ResolvedTheme,
|
||||
} from '@/features/authFiles/constants';
|
||||
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
|
||||
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
|
||||
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||
|
||||
type AuthFileHealthStatus = 'healthy' | 'warning' | 'disabled' | 'unknown';
|
||||
|
||||
const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'available']);
|
||||
const GOOD_STATUS_VALUES = new Set(['', 'ok', 'ready', 'healthy', 'available']);
|
||||
|
||||
const parseDateFromUnknown = (value: unknown): Date | null => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const asNumber = Number(value);
|
||||
const date =
|
||||
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||||
? new Date(Math.abs(asNumber) < 1e12 ? asNumber * 1000 : asNumber)
|
||||
: new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
|
||||
export type AuthFileCardProps = {
|
||||
file: AuthFileItem;
|
||||
@@ -48,11 +41,10 @@ export type AuthFileCardProps = {
|
||||
quotaFilterType: QuotaProviderType | null;
|
||||
keyStats: KeyStats;
|
||||
statusBarCache: Map<string, AuthFileStatusBarData>;
|
||||
nowMs: number;
|
||||
onShowModels: (file: AuthFileItem) => void;
|
||||
onShowDetails: (file: AuthFileItem) => void;
|
||||
onDownload: (name: string) => void;
|
||||
onOpenPrefixProxyEditor: (name: string) => void;
|
||||
onOpenPrefixProxyEditor: (file: AuthFileItem) => void;
|
||||
onDelete: (name: string) => void;
|
||||
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
|
||||
onToggleSelect: (name: string) => void;
|
||||
@@ -65,7 +57,7 @@ const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||
};
|
||||
|
||||
export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
selected,
|
||||
@@ -76,14 +68,13 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
quotaFilterType,
|
||||
keyStats,
|
||||
statusBarCache,
|
||||
nowMs,
|
||||
onShowModels,
|
||||
onShowDetails,
|
||||
onDownload,
|
||||
onOpenPrefixProxyEditor,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
onToggleSelect
|
||||
onToggleSelect,
|
||||
} = props;
|
||||
|
||||
const fileStats = resolveAuthFileStats(file, keyStats);
|
||||
@@ -110,63 +101,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
|
||||
const statusData =
|
||||
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||
const rawStatus = String(file.status ?? file['status'] ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim();
|
||||
const normalizedStatusMessage = rawStatusMessage.toLowerCase();
|
||||
const isFileDisabled = file.disabled === true || rawStatus === 'disabled';
|
||||
const isUnavailable = file.unavailable === true || rawStatus === 'unavailable';
|
||||
const lastRefreshDate = parseDateFromUnknown(file['last_refresh'] ?? file.lastRefresh);
|
||||
const isRefreshStale = lastRefreshDate
|
||||
? nowMs - lastRefreshDate.getTime() > AUTH_FILE_REFRESH_WARNING_MS
|
||||
: false;
|
||||
const hasStatusWarning =
|
||||
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(normalizedStatusMessage);
|
||||
const hasStatusFailure = rawStatus === 'error' || rawStatus === 'failed' || rawStatus === 'warning';
|
||||
const healthStatus: AuthFileHealthStatus = isFileDisabled
|
||||
? 'disabled'
|
||||
: hasStatusWarning || hasStatusFailure || isUnavailable || isRefreshStale
|
||||
? 'warning'
|
||||
: lastRefreshDate && !isRefreshStale && GOOD_STATUS_VALUES.has(rawStatus)
|
||||
? 'healthy'
|
||||
: 'unknown';
|
||||
const healthStatusClass =
|
||||
healthStatus === 'healthy'
|
||||
? styles.healthStatusHealthy
|
||||
: healthStatus === 'warning'
|
||||
? styles.healthStatusWarning
|
||||
: healthStatus === 'disabled'
|
||||
? styles.healthStatusDisabled
|
||||
: styles.healthStatusUnknown;
|
||||
const healthStatusLabel = t(`auth_files.health_status_${healthStatus}`);
|
||||
const lastRefreshText = (() => {
|
||||
if (!lastRefreshDate) return t('auth_files.refresh_not_available');
|
||||
|
||||
const diffMs = lastRefreshDate.getTime() - nowMs;
|
||||
const absMs = Math.abs(diffMs);
|
||||
if (absMs < 30 * 1000) {
|
||||
return t('auth_files.refresh_just_now');
|
||||
}
|
||||
|
||||
const units: ReadonlyArray<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
|
||||
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
|
||||
{ unit: 'hour', ms: 60 * 60 * 1000 },
|
||||
{ unit: 'minute', ms: 60 * 1000 },
|
||||
{ unit: 'second', ms: 1000 }
|
||||
];
|
||||
const matched = units.find(({ ms }) => absMs >= ms) || units[units.length - 1];
|
||||
const value = Math.round(diffMs / matched.ms);
|
||||
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat !== 'function') {
|
||||
return lastRefreshDate.toLocaleString(i18n.language);
|
||||
}
|
||||
const formatter = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' });
|
||||
return formatter.format(value, matched.unit);
|
||||
})();
|
||||
const lastRefreshTitle = lastRefreshDate
|
||||
? lastRefreshDate.toLocaleString(i18n.language)
|
||||
: t('auth_files.refresh_not_available');
|
||||
const healthStatusTitle = rawStatusMessage || t('auth_files.health_status_no_message');
|
||||
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -180,7 +117,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
type="button"
|
||||
className={`${styles.selectionToggle} ${selected ? styles.selectionToggleActive : ''}`}
|
||||
onClick={() => onToggleSelect(file.name)}
|
||||
aria-label={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||
aria-label={
|
||||
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
|
||||
}
|
||||
aria-pressed={selected}
|
||||
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||
>
|
||||
@@ -192,7 +131,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
style={{
|
||||
backgroundColor: typeColor.bg,
|
||||
color: typeColor.text,
|
||||
...(typeColor.border ? { border: typeColor.border } : {})
|
||||
...(typeColor.border ? { border: typeColor.border } : {}),
|
||||
}}
|
||||
>
|
||||
{getTypeLabel(t, file.type || 'unknown')}
|
||||
@@ -209,17 +148,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.cardHealthRow}>
|
||||
<span className={`${styles.healthStatusBadge} ${healthStatusClass}`} title={healthStatusTitle}>
|
||||
{t('auth_files.health_status_label')}: {healthStatusLabel}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.lastRefreshText} ${isRefreshStale ? styles.lastRefreshStale : ''}`}
|
||||
title={lastRefreshTitle}
|
||||
>
|
||||
{t('auth_files.last_refresh_label')}: {lastRefreshText}
|
||||
</span>
|
||||
</div>
|
||||
{rawStatusMessage && hasStatusWarning && (
|
||||
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
|
||||
{rawStatusMessage}
|
||||
@@ -238,7 +166,11 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||
|
||||
{showQuotaLayout && quotaType && (
|
||||
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
|
||||
<AuthFileQuotaSection
|
||||
file={file}
|
||||
quotaType={quotaType}
|
||||
disableControls={disableControls}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
@@ -279,7 +211,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onOpenPrefixProxyEditor(file.name)}
|
||||
onClick={() => onOpenPrefixProxyEditor(file)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.prefix_proxy_button')}
|
||||
disabled={disableControls}
|
||||
@@ -313,7 +245,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
</div>
|
||||
)}
|
||||
{isRuntimeOnly && (
|
||||
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
||||
<div className={styles.virtualBadge}>
|
||||
{t('auth_files.type_virtual') || '虚拟认证文件'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import type {
|
||||
PrefixProxyEditorField,
|
||||
PrefixProxyEditorState
|
||||
PrefixProxyEditorFieldValue,
|
||||
PrefixProxyEditorState,
|
||||
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||
|
||||
@@ -16,7 +18,7 @@ export type AuthFilesPrefixProxyEditorModalProps = {
|
||||
dirty: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onChange: (field: PrefixProxyEditorField, value: string) => void;
|
||||
onChange: (field: PrefixProxyEditorField, value: PrefixProxyEditorFieldValue) => void;
|
||||
};
|
||||
|
||||
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
|
||||
@@ -42,9 +44,7 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
<Button
|
||||
onClick={onSave}
|
||||
loading={editor?.saving === true}
|
||||
disabled={
|
||||
disableControls || editor?.saving === true || !dirty || !editor?.json
|
||||
}
|
||||
disabled={disableControls || editor?.saving === true || !dirty || !editor?.json}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
@@ -114,6 +114,18 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('disableCooling', e.target.value)}
|
||||
/>
|
||||
{editor.isCodexFile && (
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.codex_websockets_label')}</label>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(editor.websocket)}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
ariaLabel={t('ai_providers.codex_websockets_label')}
|
||||
onChange={(value) => onChange('websocket', value)}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.codex_websockets_hint')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -122,4 +134,3 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authFilesApi } from '@/services/api';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
normalizeExcludedModels,
|
||||
parseDisableCoolingValue,
|
||||
parseExcludedModelsText,
|
||||
parsePriorityValue
|
||||
parsePriorityValue,
|
||||
} from '@/features/authFiles/constants';
|
||||
|
||||
export type PrefixProxyEditorField =
|
||||
@@ -16,10 +17,14 @@ export type PrefixProxyEditorField =
|
||||
| 'proxyUrl'
|
||||
| 'priority'
|
||||
| 'excludedModelsText'
|
||||
| 'disableCooling';
|
||||
| 'disableCooling'
|
||||
| 'websocket';
|
||||
|
||||
export type PrefixProxyEditorFieldValue = string | boolean;
|
||||
|
||||
export type PrefixProxyEditorState = {
|
||||
fileName: string;
|
||||
isCodexFile: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
@@ -31,6 +36,7 @@ export type PrefixProxyEditorState = {
|
||||
priority: string;
|
||||
excludedModelsText: string;
|
||||
disableCooling: string;
|
||||
websocket: boolean;
|
||||
};
|
||||
|
||||
export type UseAuthFilesPrefixProxyEditorOptions = {
|
||||
@@ -43,9 +49,12 @@ export type UseAuthFilesPrefixProxyEditorResult = {
|
||||
prefixProxyEditor: PrefixProxyEditorState | null;
|
||||
prefixProxyUpdatedText: string;
|
||||
prefixProxyDirty: boolean;
|
||||
openPrefixProxyEditor: (name: string) => Promise<void>;
|
||||
openPrefixProxyEditor: (file: Pick<AuthFileItem, 'name' | 'type' | 'provider'>) => Promise<void>;
|
||||
closePrefixProxyEditor: () => void;
|
||||
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
|
||||
handlePrefixProxyChange: (
|
||||
field: PrefixProxyEditorField,
|
||||
value: PrefixProxyEditorFieldValue
|
||||
) => void;
|
||||
handlePrefixProxySave: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -80,6 +89,10 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
|
||||
delete next.disable_cooling;
|
||||
}
|
||||
|
||||
if (editor.isCodexFile) {
|
||||
next.websocket = editor.websocket;
|
||||
}
|
||||
|
||||
return JSON.stringify(next);
|
||||
};
|
||||
|
||||
@@ -102,7 +115,16 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
setPrefixProxyEditor(null);
|
||||
};
|
||||
|
||||
const openPrefixProxyEditor = async (name: string) => {
|
||||
const openPrefixProxyEditor = async (file: Pick<AuthFileItem, 'name' | 'type' | 'provider'>) => {
|
||||
const name = file.name;
|
||||
const normalizedType = String(file.type ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedProvider = String(file.provider ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const isCodexFile = normalizedType === 'codex' || normalizedProvider === 'codex';
|
||||
|
||||
if (disableControls) return;
|
||||
if (prefixProxyEditor?.fileName === name) {
|
||||
setPrefixProxyEditor(null);
|
||||
@@ -111,6 +133,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
|
||||
setPrefixProxyEditor({
|
||||
fileName: name,
|
||||
isCodexFile,
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: null,
|
||||
@@ -121,7 +144,8 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
proxyUrl: '',
|
||||
priority: '',
|
||||
excludedModelsText: '',
|
||||
disableCooling: ''
|
||||
disableCooling: '',
|
||||
websocket: false,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -139,7 +163,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
loading: false,
|
||||
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||
rawText: trimmed,
|
||||
originalText: trimmed
|
||||
originalText: trimmed,
|
||||
};
|
||||
});
|
||||
return;
|
||||
@@ -153,19 +177,24 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
loading: false,
|
||||
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||
rawText: trimmed,
|
||||
originalText: trimmed
|
||||
originalText: trimmed,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const json = parsed as Record<string, unknown>;
|
||||
const json = { ...(parsed as Record<string, unknown>) };
|
||||
if (isCodexFile) {
|
||||
const websocketValue = parseDisableCoolingValue(json.websocket);
|
||||
json.websocket = websocketValue ?? false;
|
||||
}
|
||||
const originalText = JSON.stringify(json);
|
||||
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
|
||||
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
|
||||
const priority = parsePriorityValue(json.priority);
|
||||
const excludedModels = normalizeExcludedModels(json.excluded_models);
|
||||
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
|
||||
const websocketValue = parseDisableCoolingValue(json.websocket);
|
||||
|
||||
setPrefixProxyEditor((prev) => {
|
||||
if (!prev || prev.fileName !== name) return prev;
|
||||
@@ -181,7 +210,8 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
excludedModelsText: excludedModels.join('\n'),
|
||||
disableCooling:
|
||||
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
|
||||
error: null
|
||||
websocket: websocketValue ?? false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
@@ -194,14 +224,18 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
|
||||
const handlePrefixProxyChange = (
|
||||
field: PrefixProxyEditorField,
|
||||
value: PrefixProxyEditorFieldValue
|
||||
) => {
|
||||
setPrefixProxyEditor((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (field === 'prefix') return { ...prev, prefix: value };
|
||||
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
|
||||
if (field === 'priority') return { ...prev, priority: value };
|
||||
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
|
||||
return { ...prev, disableCooling: value };
|
||||
if (field === 'prefix') return { ...prev, prefix: String(value) };
|
||||
if (field === 'proxyUrl') return { ...prev, proxyUrl: String(value) };
|
||||
if (field === 'priority') return { ...prev, priority: String(value) };
|
||||
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
|
||||
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
|
||||
return { ...prev, websocket: Boolean(value) };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -249,6 +283,6 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
openPrefixProxyEditor,
|
||||
closePrefixProxyEditor,
|
||||
handlePrefixProxyChange,
|
||||
handlePrefixProxySave
|
||||
handlePrefixProxySave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1023,6 +1023,7 @@
|
||||
"trace_confidence_low": "Low",
|
||||
"trace_score": "Score {{score}}",
|
||||
"trace_delta_seconds": "Δt {{seconds}}s",
|
||||
"trace_model_matched": "Model Matched",
|
||||
"trace_request_id": "Request ID",
|
||||
"trace_method": "Method",
|
||||
"trace_path": "Path",
|
||||
|
||||
@@ -1026,6 +1026,7 @@
|
||||
"trace_confidence_low": "Низкая",
|
||||
"trace_score": "Оценка {{score}}",
|
||||
"trace_delta_seconds": "Δt {{seconds}}с",
|
||||
"trace_model_matched": "Модель совпала",
|
||||
"trace_request_id": "Request ID",
|
||||
"trace_method": "Метод",
|
||||
"trace_path": "Путь",
|
||||
|
||||
@@ -1023,6 +1023,7 @@
|
||||
"trace_confidence_low": "低",
|
||||
"trace_score": "分数 {{score}}",
|
||||
"trace_delta_seconds": "时间差 {{seconds}} 秒",
|
||||
"trace_model_matched": "模型匹配",
|
||||
"trace_request_id": "请求 ID",
|
||||
"trace_method": "请求方法",
|
||||
"trace_path": "路径",
|
||||
|
||||
@@ -271,10 +271,28 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={() => void saveAmpcode()} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
hideTopBarBackButton
|
||||
hideTopBarRightAction
|
||||
floatingAction={
|
||||
<div className={layoutStyles.floatingActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={layoutStyles.floatingBackButton}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void saveAmpcode()}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
className={layoutStyles.floatingSaveButton}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
|
||||
@@ -272,10 +272,28 @@ export function AiProvidersClaudeEditPage() {
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={() => void handleSave()} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
hideTopBarBackButton
|
||||
hideTopBarRightAction
|
||||
floatingAction={
|
||||
<div className={layoutStyles.floatingActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={layoutStyles.floatingBackButton}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSave()}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
className={layoutStyles.floatingSaveButton}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
|
||||
@@ -163,10 +163,27 @@ export function AiProvidersClaudeModelsPage() {
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleApply} disabled={!canApply}>
|
||||
{t('ai_providers.claude_models_fetch_apply')}
|
||||
</Button>
|
||||
hideTopBarBackButton
|
||||
hideTopBarRightAction
|
||||
floatingAction={
|
||||
<div className={layoutStyles.floatingActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={layoutStyles.floatingBackButton}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
disabled={!canApply}
|
||||
className={layoutStyles.floatingSaveButton}
|
||||
>
|
||||
{t('ai_providers.claude_models_fetch_apply')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isLoading={initialLoading}
|
||||
loadingLabel={t('common.loading')}
|
||||
|
||||
@@ -417,10 +417,28 @@ export function AiProvidersCodexEditPage() {
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
hideTopBarBackButton
|
||||
hideTopBarRightAction
|
||||
floatingAction={
|
||||
<div className={layoutStyles.floatingActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={layoutStyles.floatingBackButton}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
className={layoutStyles.floatingSaveButton}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.floatingActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.floatingBackButton {
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
.floatingSaveButton {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.upstreamApiKeyRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -411,10 +411,28 @@ export function AiProvidersGeminiEditPage() {
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
hideTopBarBackButton
|
||||
hideTopBarRightAction
|
||||
floatingAction={
|
||||
<div className={layoutStyles.floatingActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={layoutStyles.floatingBackButton}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
className={layoutStyles.floatingSaveButton}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
|
||||
@@ -501,10 +501,28 @@ export function AiProvidersOpenAIEditPage() {
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={() => void handleSave()} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
hideTopBarBackButton
|
||||
hideTopBarRightAction
|
||||
floatingAction={
|
||||
<div className={layoutStyles.floatingActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={layoutStyles.floatingBackButton}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSave()}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
className={layoutStyles.floatingSaveButton}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
|
||||
@@ -144,10 +144,27 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleApply} disabled={!canApply}>
|
||||
{t('ai_providers.openai_models_fetch_apply')}
|
||||
</Button>
|
||||
hideTopBarBackButton
|
||||
hideTopBarRightAction
|
||||
floatingAction={
|
||||
<div className={layoutStyles.floatingActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={layoutStyles.floatingBackButton}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
disabled={!canApply}
|
||||
className={layoutStyles.floatingSaveButton}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_apply')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isLoading={initialLoading}
|
||||
loadingLabel={t('common.loading')}
|
||||
|
||||
@@ -258,10 +258,28 @@ export function AiProvidersVertexEditPage() {
|
||||
onBack={handleBack}
|
||||
backLabel={t('common.back')}
|
||||
backAriaLabel={t('common.back')}
|
||||
rightAction={
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
hideTopBarBackButton
|
||||
hideTopBarRightAction
|
||||
floatingAction={
|
||||
<div className={layoutStyles.floatingActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={layoutStyles.floatingBackButton}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
className={layoutStyles.floatingSaveButton}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isLoading={loading}
|
||||
loadingLabel={t('common.loading')}
|
||||
|
||||
@@ -605,59 +605,6 @@
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cardHealthRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.healthStatusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.healthStatusHealthy {
|
||||
color: var(--success-badge-text, #065f46);
|
||||
background-color: var(--success-badge-bg, #d1fae5);
|
||||
border-color: var(--success-badge-border, #6ee7b7);
|
||||
}
|
||||
|
||||
.healthStatusWarning {
|
||||
color: var(--warning-text);
|
||||
background-color: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
}
|
||||
|
||||
.healthStatusDisabled {
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.healthStatusUnknown {
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.lastRefreshText {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.lastRefreshStale {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.healthStatusMessage {
|
||||
font-size: 12px;
|
||||
color: var(--warning-text);
|
||||
|
||||
@@ -58,7 +58,6 @@ export function AuthFilesPage() {
|
||||
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
|
||||
const previousSelectionCountRef = useRef(0);
|
||||
const selectionCountRef = useRef(0);
|
||||
@@ -223,7 +222,6 @@ export function AuthFilesPage() {
|
||||
},
|
||||
isCurrentLayer ? 240_000 : null
|
||||
);
|
||||
useInterval(() => setNowMs(Date.now()), isCurrentLayer ? 60_000 : null);
|
||||
|
||||
const existingTypes = useMemo(() => {
|
||||
const types = new Set<string>(['all']);
|
||||
@@ -519,7 +517,6 @@ export function AuthFilesPage() {
|
||||
quotaFilterType={quotaFilterType}
|
||||
keyStats={keyStats}
|
||||
statusBarCache={statusBarCache}
|
||||
nowMs={nowMs}
|
||||
onShowModels={showModels}
|
||||
onShowDetails={showDetails}
|
||||
onDownload={handleDownload}
|
||||
|
||||
@@ -612,6 +612,13 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.traceCandidatesHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.traceInfoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -686,34 +693,18 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.traceConfidenceBadge {
|
||||
.traceModelBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--success-badge-border, #6ee7b7);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.traceConfidenceHigh {
|
||||
color: var(--success-badge-text, #065f46);
|
||||
background: var(--success-badge-bg, #d1fae5);
|
||||
border-color: var(--success-badge-border, #6ee7b7);
|
||||
}
|
||||
|
||||
.traceConfidenceMedium {
|
||||
color: var(--warning-text);
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
}
|
||||
|
||||
.traceConfidenceLow {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.traceScore,
|
||||
.traceDelta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
+19
-13
@@ -947,7 +947,20 @@ export function LogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className={styles.traceSectionTitle}>{t('logs.trace_candidates_title')}</h3>
|
||||
<div className={styles.traceCandidatesHeader}>
|
||||
<h3 className={styles.traceSectionTitle}>{t('logs.trace_candidates_title')}</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void trace.refreshTraceUsageDetails().catch(() => {});
|
||||
}}
|
||||
loading={trace.traceLoading}
|
||||
disabled={requestLogDownloading}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
{trace.traceLoading ? (
|
||||
<div className="hint">{t('logs.trace_loading')}</div>
|
||||
) : trace.traceError ? (
|
||||
@@ -957,12 +970,6 @@ export function LogsPage() {
|
||||
) : (
|
||||
<div className={styles.traceCandidates}>
|
||||
{trace.traceCandidates.map((candidate) => {
|
||||
const confidenceClass =
|
||||
candidate.confidence === 'high'
|
||||
? styles.traceConfidenceHigh
|
||||
: candidate.confidence === 'medium'
|
||||
? styles.traceConfidenceMedium
|
||||
: styles.traceConfidenceLow;
|
||||
const sourceInfo = trace.resolveTraceSourceInfo(
|
||||
String(candidate.detail.source ?? ''),
|
||||
candidate.detail.auth_index
|
||||
@@ -973,12 +980,11 @@ export function LogsPage() {
|
||||
className={styles.traceCandidate}
|
||||
>
|
||||
<div className={styles.traceCandidateHeader}>
|
||||
<span className={`${styles.traceConfidenceBadge} ${confidenceClass}`}>
|
||||
{t(`logs.trace_confidence_${candidate.confidence}`)}
|
||||
</span>
|
||||
<span className={styles.traceScore}>
|
||||
{t('logs.trace_score', { score: candidate.score })}
|
||||
</span>
|
||||
{candidate.modelMatched && (
|
||||
<span className={styles.traceModelBadge}>
|
||||
{t('logs.trace_model_matched')}
|
||||
</span>
|
||||
)}
|
||||
{candidate.timeDeltaMs !== null && (
|
||||
<span className={styles.traceDelta}>
|
||||
{t('logs.trace_delta_seconds', {
|
||||
|
||||
@@ -187,6 +187,8 @@ export function UsagePage() {
|
||||
}
|
||||
}, [timeRange]);
|
||||
|
||||
const nowMs = lastRefreshedAt?.getTime() ?? 0;
|
||||
|
||||
// Sparklines hook
|
||||
const {
|
||||
requestsSparkline,
|
||||
@@ -194,7 +196,7 @@ export function UsagePage() {
|
||||
rpmSparkline,
|
||||
tpmSparkline,
|
||||
costSparkline
|
||||
} = useSparklines({ usage: filteredUsage, loading });
|
||||
} = useSparklines({ usage: filteredUsage, loading, nowMs });
|
||||
|
||||
// Chart data hook
|
||||
const {
|
||||
@@ -293,6 +295,7 @@ export function UsagePage() {
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
modelPrices={modelPrices}
|
||||
nowMs={nowMs}
|
||||
sparklines={{
|
||||
requests: requestsSparkline,
|
||||
tokens: tokensSparkline,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
|
||||
import type { AuthFileItem, Config } from '@/types';
|
||||
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
@@ -12,19 +12,14 @@ import {
|
||||
} from '@/utils/usage';
|
||||
import type { ParsedLogLine } from './logTypes';
|
||||
|
||||
type TraceConfidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export type TraceCandidate = {
|
||||
detail: UsageDetailWithEndpoint;
|
||||
score: number;
|
||||
confidence: TraceConfidence;
|
||||
modelMatched: boolean;
|
||||
timeDeltaMs: number | null;
|
||||
};
|
||||
|
||||
const TRACE_USAGE_CACHE_MS = 60 * 1000;
|
||||
const TRACE_MATCH_STRONG_WINDOW_MS = 3 * 1000;
|
||||
const TRACE_MATCH_WINDOW_MS = 10 * 1000;
|
||||
const TRACE_MATCH_MAX_WINDOW_MS = 30 * 1000;
|
||||
const TRACE_AUTH_CACHE_MS = 60 * 1000;
|
||||
const TRACE_MAX_CANDIDATES = 5;
|
||||
|
||||
const TRACEABLE_EXACT_PATHS = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses']);
|
||||
const TRACEABLE_PREFIX_PATHS = ['/v1beta/models'];
|
||||
@@ -48,70 +43,17 @@ export const isTraceableRequestPath = (value?: string): boolean => {
|
||||
return TRACEABLE_PREFIX_PATHS.some((prefix) => normalizedPath.startsWith(prefix));
|
||||
};
|
||||
|
||||
const scoreTraceCandidate = (
|
||||
line: ParsedLogLine,
|
||||
detail: UsageDetailWithEndpoint
|
||||
): TraceCandidate | null => {
|
||||
let score = 0;
|
||||
let timeDeltaMs: number | null = null;
|
||||
const MODEL_EXTRACT_REGEX = /\bmodel[=:]\s*"?([a-zA-Z0-9._\-/]+)"?/i;
|
||||
|
||||
const logTimestampMs = line.timestamp ? Date.parse(line.timestamp) : Number.NaN;
|
||||
const detailTimestampMs = detail.__timestampMs;
|
||||
if (!Number.isNaN(logTimestampMs) && detailTimestampMs > 0) {
|
||||
timeDeltaMs = Math.abs(logTimestampMs - detailTimestampMs);
|
||||
if (timeDeltaMs <= TRACE_MATCH_STRONG_WINDOW_MS) {
|
||||
score += 42;
|
||||
} else if (timeDeltaMs <= TRACE_MATCH_WINDOW_MS) {
|
||||
score += 30;
|
||||
} else if (timeDeltaMs <= TRACE_MATCH_MAX_WINDOW_MS) {
|
||||
score += 12;
|
||||
} else {
|
||||
score -= 12;
|
||||
}
|
||||
}
|
||||
const extractModelFromMessage = (message?: string): string | undefined => {
|
||||
if (!message) return undefined;
|
||||
const match = message.match(MODEL_EXTRACT_REGEX);
|
||||
return match?.[1] || undefined;
|
||||
};
|
||||
|
||||
let methodMatched = false;
|
||||
if (line.method && detail.__endpointMethod) {
|
||||
if (line.method.toUpperCase() === detail.__endpointMethod.toUpperCase()) {
|
||||
score += 18;
|
||||
methodMatched = true;
|
||||
} else {
|
||||
score -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
const logPath = normalizeTracePath(line.path);
|
||||
const detailPath = normalizeTracePath(detail.__endpointPath);
|
||||
let pathMatched = false;
|
||||
if (logPath && detailPath) {
|
||||
if (logPath === detailPath) {
|
||||
score += 24;
|
||||
pathMatched = true;
|
||||
} else if (logPath.startsWith(detailPath) || detailPath.startsWith(logPath)) {
|
||||
score += 12;
|
||||
pathMatched = true;
|
||||
} else {
|
||||
score -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof line.statusCode === 'number') {
|
||||
const logFailed = line.statusCode >= 400;
|
||||
score += logFailed === detail.failed ? 10 : -6;
|
||||
}
|
||||
|
||||
if (
|
||||
timeDeltaMs !== null &&
|
||||
timeDeltaMs > TRACE_MATCH_MAX_WINDOW_MS &&
|
||||
!methodMatched &&
|
||||
!pathMatched
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (score <= 0) return null;
|
||||
const confidence: TraceConfidence = score >= 70 ? 'high' : score >= 45 ? 'medium' : 'low';
|
||||
return { detail, score, confidence, timeDeltaMs };
|
||||
const isPathMatch = (logPath: string, detailPath: string): boolean => {
|
||||
if (!logPath || !detailPath) return false;
|
||||
return logPath === detailPath || logPath.startsWith(detailPath) || detailPath.startsWith(logPath);
|
||||
};
|
||||
|
||||
const getErrorMessage = (err: unknown): string => {
|
||||
@@ -138,6 +80,7 @@ interface UseTraceResolverReturn {
|
||||
traceCandidates: TraceCandidate[];
|
||||
resolveTraceSourceInfo: (sourceRaw: string, authIndex: unknown) => SourceInfo;
|
||||
loadTraceUsageDetails: () => Promise<void>;
|
||||
refreshTraceUsageDetails: () => Promise<void>;
|
||||
openTraceModal: (line: ParsedLogLine) => void;
|
||||
closeTraceModal: () => void;
|
||||
}
|
||||
@@ -145,25 +88,30 @@ interface UseTraceResolverReturn {
|
||||
export function useTraceResolver(options: UseTraceResolverOptions): UseTraceResolverReturn {
|
||||
const { traceScopeKey, connectionStatus, config, requestLogDownloading } = options;
|
||||
const { t } = useTranslation();
|
||||
const usageSnapshot = useUsageStatsStore((state) => state.usage);
|
||||
const usageScopeKey = useUsageStatsStore((state) => state.scopeKey);
|
||||
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
|
||||
|
||||
const [traceLogLine, setTraceLogLine] = useState<ParsedLogLine | null>(null);
|
||||
const [traceUsageDetails, setTraceUsageDetails] = useState<UsageDetailWithEndpoint[]>([]);
|
||||
const [traceAuthFileMap, setTraceAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
|
||||
const [traceLoading, setTraceLoading] = useState(false);
|
||||
const [traceError, setTraceError] = useState('');
|
||||
|
||||
const traceUsageLoadedAtRef = useRef(0);
|
||||
const traceAuthLoadedAtRef = useRef(0);
|
||||
const traceScopeKeyRef = useRef('');
|
||||
|
||||
const scopedUsageSnapshot = usageScopeKey === traceScopeKey ? usageSnapshot : null;
|
||||
const traceUsageDetails = useMemo<UsageDetailWithEndpoint[]>(
|
||||
() => collectUsageDetailsWithEndpoint(scopedUsageSnapshot),
|
||||
[scopedUsageSnapshot]
|
||||
);
|
||||
|
||||
const traceSourceInfoMap = useMemo(() => buildSourceInfoMap(config ?? {}), [config]);
|
||||
|
||||
const loadTraceUsageDetails = useCallback(async () => {
|
||||
const loadTraceUsageDetailsInternal = useCallback(async (forceUsage: boolean) => {
|
||||
if (traceScopeKeyRef.current !== traceScopeKey) {
|
||||
traceScopeKeyRef.current = traceScopeKey;
|
||||
traceUsageLoadedAtRef.current = 0;
|
||||
traceAuthLoadedAtRef.current = 0;
|
||||
setTraceUsageDetails([]);
|
||||
setTraceAuthFileMap(new Map());
|
||||
setTraceError('');
|
||||
}
|
||||
@@ -171,27 +119,20 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
|
||||
if (traceLoading) return;
|
||||
|
||||
const now = Date.now();
|
||||
const usageFresh =
|
||||
traceUsageLoadedAtRef.current > 0 && now - traceUsageLoadedAtRef.current < TRACE_USAGE_CACHE_MS;
|
||||
const authFresh =
|
||||
traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_USAGE_CACHE_MS;
|
||||
if (usageFresh && authFresh) return;
|
||||
traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_AUTH_CACHE_MS;
|
||||
|
||||
setTraceLoading(true);
|
||||
setTraceError('');
|
||||
try {
|
||||
const [usageResponse, authFilesResponse] = await Promise.all([
|
||||
usageFresh ? Promise.resolve(null) : usageApi.getUsage(),
|
||||
const [, authFilesResponse] = await Promise.all([
|
||||
loadUsageStats({
|
||||
force: forceUsage,
|
||||
staleTimeMs: USAGE_STATS_STALE_TIME_MS
|
||||
}),
|
||||
authFresh ? Promise.resolve(null) : authFilesApi.list().catch(() => null)
|
||||
]);
|
||||
|
||||
if (usageResponse !== null) {
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const details = collectUsageDetailsWithEndpoint(usageData);
|
||||
setTraceUsageDetails(details);
|
||||
traceUsageLoadedAtRef.current = now;
|
||||
}
|
||||
|
||||
if (authFilesResponse !== null) {
|
||||
const files = Array.isArray(authFilesResponse)
|
||||
? authFilesResponse
|
||||
@@ -207,7 +148,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
|
||||
});
|
||||
});
|
||||
setTraceAuthFileMap(map);
|
||||
traceAuthLoadedAtRef.current = now;
|
||||
traceAuthLoadedAtRef.current = Date.now();
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -215,14 +156,20 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
|
||||
} finally {
|
||||
setTraceLoading(false);
|
||||
}
|
||||
}, [t, traceLoading, traceScopeKey]);
|
||||
}, [loadUsageStats, t, traceLoading, traceScopeKey]);
|
||||
|
||||
const loadTraceUsageDetails = useCallback(async () => {
|
||||
await loadTraceUsageDetailsInternal(false);
|
||||
}, [loadTraceUsageDetailsInternal]);
|
||||
|
||||
const refreshTraceUsageDetails = useCallback(async () => {
|
||||
await loadTraceUsageDetailsInternal(true);
|
||||
}, [loadTraceUsageDetailsInternal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === 'connected') {
|
||||
traceScopeKeyRef.current = traceScopeKey;
|
||||
traceUsageLoadedAtRef.current = 0;
|
||||
traceAuthLoadedAtRef.current = 0;
|
||||
setTraceUsageDetails([]);
|
||||
setTraceAuthFileMap(new Map());
|
||||
setTraceLoading(false);
|
||||
setTraceError('');
|
||||
@@ -231,16 +178,42 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
|
||||
|
||||
const traceCandidates = useMemo(() => {
|
||||
if (!traceLogLine) return [];
|
||||
const scored = traceUsageDetails
|
||||
.map((detail) => scoreTraceCandidate(traceLogLine, detail))
|
||||
.filter((item): item is TraceCandidate => item !== null)
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
const aDelta = a.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
|
||||
const bDelta = b.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
|
||||
return aDelta - bDelta;
|
||||
});
|
||||
return scored.slice(0, 8);
|
||||
|
||||
const logPath = normalizeTracePath(traceLogLine.path);
|
||||
if (!logPath) return [];
|
||||
|
||||
const logTimestampMs = traceLogLine.timestamp
|
||||
? Date.parse(traceLogLine.timestamp)
|
||||
: Number.NaN;
|
||||
|
||||
// Step 1: filter by path match
|
||||
const pathMatched = traceUsageDetails.filter((detail) =>
|
||||
isPathMatch(logPath, normalizeTracePath(detail.__endpointPath))
|
||||
);
|
||||
if (pathMatched.length === 0) return [];
|
||||
|
||||
// Step 2: try to extract model from log message, then filter by model
|
||||
const logModel = extractModelFromMessage(traceLogLine.message);
|
||||
const modelMatched = logModel
|
||||
? pathMatched.filter(
|
||||
(d) => d.__modelName?.toLowerCase() === logModel.toLowerCase()
|
||||
)
|
||||
: [];
|
||||
|
||||
// Step 3: prefer model-matched set; fall back to path-matched
|
||||
const useModelSet = modelMatched.length > 0;
|
||||
const source = useModelSet ? modelMatched : pathMatched;
|
||||
|
||||
return source
|
||||
.map((detail) => {
|
||||
const timeDeltaMs =
|
||||
!Number.isNaN(logTimestampMs) && detail.__timestampMs > 0
|
||||
? Math.abs(logTimestampMs - detail.__timestampMs)
|
||||
: null;
|
||||
return { detail, modelMatched: useModelSet, timeDeltaMs } satisfies TraceCandidate;
|
||||
})
|
||||
.sort((a, b) => (b.detail.__timestampMs || 0) - (a.detail.__timestampMs || 0))
|
||||
.slice(0, TRACE_MAX_CANDIDATES);
|
||||
}, [traceLogLine, traceUsageDetails]);
|
||||
|
||||
const resolveTraceSourceInfo = useCallback(
|
||||
@@ -271,6 +244,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
|
||||
traceCandidates,
|
||||
resolveTraceSourceInfo,
|
||||
loadTraceUsageDetails,
|
||||
refreshTraceUsageDetails,
|
||||
openTraceModal,
|
||||
closeTraceModal
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { usageApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { collectUsageDetails, computeKeyStats, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import { collectUsageDetails, computeKeyStatsFromDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export const USAGE_STATS_STALE_TIME_MS = 240_000;
|
||||
@@ -98,10 +98,11 @@ export const useUsageStatsStore = create<UsageStatsState>((set, get) => ({
|
||||
|
||||
if (requestId !== usageRequestToken) return;
|
||||
|
||||
const usageDetails = collectUsageDetails(usage);
|
||||
set({
|
||||
usage,
|
||||
keyStats: computeKeyStats(usage),
|
||||
usageDetails: collectUsageDetails(usage),
|
||||
keyStats: computeKeyStatsFromDetails(usageDetails),
|
||||
usageDetails,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastRefreshedAt: Date.now(),
|
||||
|
||||
+166
-60
@@ -49,6 +49,7 @@ export interface UsageDetail {
|
||||
};
|
||||
failed: boolean;
|
||||
__modelName?: string;
|
||||
__timestampMs?: number;
|
||||
}
|
||||
|
||||
export interface UsageDetailWithEndpoint extends UsageDetail {
|
||||
@@ -109,34 +110,6 @@ const toUsageSummaryFields = (summary: UsageSummary) => ({
|
||||
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;
|
||||
@@ -169,6 +142,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
|
||||
const filteredModels: Record<string, unknown> = {};
|
||||
const apiSummary = createUsageSummary();
|
||||
let hasModelData = false;
|
||||
|
||||
Object.entries(models).forEach(([modelName, modelEntry]) => {
|
||||
if (!isRecord(modelEntry)) {
|
||||
@@ -176,22 +150,39 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
}
|
||||
|
||||
const detailsRaw = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
||||
const filteredDetails = detailsRaw.filter((detail) =>
|
||||
isDetailWithinWindow(detail, windowStart, nowMs)
|
||||
);
|
||||
const modelSummary = createUsageSummary();
|
||||
const filteredDetails: unknown[] = [];
|
||||
|
||||
detailsRaw.forEach((detail) => {
|
||||
const detailRecord = isRecord(detail) ? detail : null;
|
||||
if (!detailRecord || typeof detailRecord.timestamp !== 'string') {
|
||||
return;
|
||||
}
|
||||
const timestamp = Date.parse(detailRecord.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
filteredDetails.push(detail);
|
||||
modelSummary.totalRequests += 1;
|
||||
if (detailRecord.failed === true) {
|
||||
modelSummary.failureCount += 1;
|
||||
} else {
|
||||
modelSummary.successCount += 1;
|
||||
}
|
||||
modelSummary.totalTokens += extractTotalTokens(detailRecord);
|
||||
});
|
||||
|
||||
if (!filteredDetails.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelSummary = createUsageSummary();
|
||||
updateSummaryFromDetails(modelSummary, filteredDetails);
|
||||
|
||||
filteredModels[modelName] = {
|
||||
...modelEntry,
|
||||
...toUsageSummaryFields(modelSummary),
|
||||
details: filteredDetails
|
||||
};
|
||||
hasModelData = true;
|
||||
|
||||
apiSummary.totalRequests += modelSummary.totalRequests;
|
||||
apiSummary.successCount += modelSummary.successCount;
|
||||
@@ -199,7 +190,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
apiSummary.totalTokens += modelSummary.totalTokens;
|
||||
});
|
||||
|
||||
if (Object.keys(filteredModels).length === 0) {
|
||||
if (!hasModelData) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -450,13 +441,40 @@ export function formatUsd(value: number): string {
|
||||
return `$${parts}`;
|
||||
}
|
||||
|
||||
const usageDetailsCache = new WeakMap<object, UsageDetail[]>();
|
||||
const usageDetailsWithEndpointCache = new WeakMap<object, UsageDetailWithEndpoint[]>();
|
||||
|
||||
/**
|
||||
* 从使用数据中收集所有请求明细
|
||||
*/
|
||||
export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
const cacheKey = isRecord(usageData) ? (usageData as object) : null;
|
||||
if (cacheKey) {
|
||||
const cached = usageDetailsCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) return [];
|
||||
const details: UsageDetail[] = [];
|
||||
const sourceCache = new Map<string, string>();
|
||||
|
||||
const normalizeSource = (value: unknown): string => {
|
||||
const raw =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: value === null || value === undefined
|
||||
? ''
|
||||
: String(value);
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
const cached = sourceCache.get(trimmed);
|
||||
if (cached !== undefined) return cached;
|
||||
const normalized = normalizeUsageSourceId(trimmed);
|
||||
sourceCache.set(trimmed, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
Object.values(apis).forEach((apiEntry) => {
|
||||
if (!isRecord(apiEntry)) return;
|
||||
const modelsRaw = apiEntry.models;
|
||||
@@ -470,15 +488,25 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
|
||||
modelDetails.forEach((detailRaw) => {
|
||||
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
|
||||
const detail = detailRaw as unknown as UsageDetail;
|
||||
const timestamp = detailRaw.timestamp;
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
|
||||
details.push({
|
||||
...detail,
|
||||
source: normalizeUsageSourceId(detail.source),
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index: detailRaw.auth_index as unknown as number,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
__timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (cacheKey) {
|
||||
usageDetailsCache.set(cacheKey, details);
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
@@ -486,10 +514,34 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
* 从使用数据中收集包含 endpoint/method/path 的请求明细
|
||||
*/
|
||||
export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetailWithEndpoint[] {
|
||||
const cacheKey = isRecord(usageData) ? (usageData as object) : null;
|
||||
if (cacheKey) {
|
||||
const cached = usageDetailsWithEndpointCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) return [];
|
||||
|
||||
const details: UsageDetailWithEndpoint[] = [];
|
||||
const sourceCache = new Map<string, string>();
|
||||
|
||||
const normalizeSource = (value: unknown): string => {
|
||||
const raw =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: value === null || value === undefined
|
||||
? ''
|
||||
: String(value);
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
const cached = sourceCache.get(trimmed);
|
||||
if (cached !== undefined) return cached;
|
||||
const normalized = normalizeUsageSourceId(trimmed);
|
||||
sourceCache.set(trimmed, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
Object.entries(apis).forEach(([endpoint, apiEntry]) => {
|
||||
if (!isRecord(apiEntry)) return;
|
||||
const modelsRaw = apiEntry.models;
|
||||
@@ -507,11 +559,15 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
|
||||
|
||||
modelDetails.forEach((detailRaw) => {
|
||||
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
|
||||
const detail = detailRaw as unknown as UsageDetail;
|
||||
const timestampMs = Date.parse(detail.timestamp);
|
||||
const timestamp = detailRaw.timestamp;
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
|
||||
details.push({
|
||||
...detail,
|
||||
source: normalizeUsageSourceId(detail.source),
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index: detailRaw.auth_index as unknown as number,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
__endpoint: endpoint,
|
||||
__endpointMethod: endpointMethod,
|
||||
@@ -522,6 +578,9 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
|
||||
});
|
||||
});
|
||||
|
||||
if (cacheKey) {
|
||||
usageDetailsWithEndpointCache.set(cacheKey, details);
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
@@ -592,8 +651,9 @@ export function calculateRecentPerMinuteRates(
|
||||
let tokenCount = 0;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
requestCount += 1;
|
||||
@@ -951,8 +1011,9 @@ export function buildHourlySeriesByModel(
|
||||
}
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1007,8 +1068,9 @@ export function buildDailySeriesByModel(
|
||||
}
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
@@ -1220,8 +1282,9 @@ export function calculateStatusBarData(
|
||||
|
||||
// Filter and bucket the usage details
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1322,8 +1385,9 @@ export function calculateServiceHealthData(
|
||||
let totalFailure = 0;
|
||||
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1439,6 +1503,44 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
|
||||
};
|
||||
}
|
||||
|
||||
export function computeKeyStatsFromDetails(usageDetails: UsageDetail[]): KeyStats {
|
||||
const bySource: Record<string, KeyStatBucket> = {};
|
||||
const byAuthIndex: Record<string, KeyStatBucket> = {};
|
||||
|
||||
const ensureBucket = (bucket: Record<string, KeyStatBucket>, key: string) => {
|
||||
if (!bucket[key]) {
|
||||
bucket[key] = { success: 0, failure: 0 };
|
||||
}
|
||||
return bucket[key];
|
||||
};
|
||||
|
||||
usageDetails.forEach((detail) => {
|
||||
const source = detail.source;
|
||||
const authIndexKey = normalizeAuthIndex(detail.auth_index);
|
||||
const isFailed = detail.failed === true;
|
||||
|
||||
if (source) {
|
||||
const bucket = ensureBucket(bySource, source);
|
||||
if (isFailed) {
|
||||
bucket.failure += 1;
|
||||
} else {
|
||||
bucket.success += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (authIndexKey) {
|
||||
const bucket = ensureBucket(byAuthIndex, authIndexKey);
|
||||
if (isFailed) {
|
||||
bucket.failure += 1;
|
||||
} else {
|
||||
bucket.success += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { bySource, byAuthIndex };
|
||||
}
|
||||
|
||||
export type TokenCategory = 'input' | 'output' | 'cached' | 'reasoning';
|
||||
|
||||
export interface TokenBreakdownSeries {
|
||||
@@ -1483,8 +1585,9 @@ export function buildHourlyTokenBreakdown(
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
const bucketStart = normalized.getTime();
|
||||
@@ -1521,8 +1624,9 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
@@ -1594,8 +1698,9 @@ export function buildHourlyCostSeries(
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
const bucketStart = normalized.getTime();
|
||||
@@ -1626,8 +1731,9 @@ export function buildDailyCostSeries(
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user