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
2 Commits
@@ -9,7 +9,6 @@ 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,
|
||||
@@ -23,20 +22,7 @@ import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFi
|
||||
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,7 +34,6 @@ 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;
|
||||
@@ -65,7 +50,7 @@ const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||
};
|
||||
|
||||
export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
selected,
|
||||
@@ -76,7 +61,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
quotaFilterType,
|
||||
keyStats,
|
||||
statusBarCache,
|
||||
nowMs,
|
||||
onShowModels,
|
||||
onShowDetails,
|
||||
onDownload,
|
||||
@@ -110,63 +94,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
|
||||
@@ -209,17 +139,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}
|
||||
|
||||
@@ -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": "路径",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -693,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);
|
||||
|
||||
+5
-12
@@ -970,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
|
||||
@@ -986,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', {
|
||||
|
||||
@@ -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_AUTH_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_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 => {
|
||||
@@ -236,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(
|
||||
|
||||
Reference in New Issue
Block a user