Compare commits

..

12 Commits

18 changed files with 2079 additions and 972 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -7,7 +7,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
@@ -173,6 +173,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
export function MainLayout() { export function MainLayout() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase); const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion); const serverVersion = useAuthStore((state) => state.serverVersion);
@@ -207,6 +208,7 @@ export function MainLayout() {
const requestLogEnabled = config?.requestLog ?? false; const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled; const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config); const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动 // 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -503,8 +505,8 @@ export function MainLayout() {
</div> </div>
</aside> </aside>
<div className="content"> <div className={`content${isLogsPage ? ' content-logs' : ''}`}>
<main className="main-content"> <main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
<Outlet /> <Outlet />
</main> </main>
@@ -512,7 +514,7 @@ export function MainLayout() {
<span> <span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')} {t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span> </span>
<span onClick={handleVersionTap}> <span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')} {t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span> </span>
<span> <span>

View File

@@ -1,4 +1,4 @@
import type { PropsWithChildren, ReactNode } from 'react'; import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
import { IconX } from './icons'; import { IconX } from './icons';
interface ModalProps { interface ModalProps {
@@ -9,23 +9,70 @@ interface ModalProps {
width?: number | string; width?: number | string;
} }
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) { const CLOSE_ANIMATION_DURATION = 350;
if (!open) return null;
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => { export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
if (event.target === event.currentTarget) { const [isVisible, setIsVisible] = useState(false);
onClose(); const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startClose = useCallback(
(notifyParent: boolean) => {
if (closeTimerRef.current !== null) return;
setIsClosing(true);
closeTimerRef.current = window.setTimeout(() => {
setIsVisible(false);
setIsClosing(false);
closeTimerRef.current = null;
if (notifyParent) {
onClose();
}
}, CLOSE_ANIMATION_DURATION);
},
[onClose]
);
useEffect(() => {
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsVisible(true);
setIsClosing(false);
return;
} }
};
if (isVisible) {
startClose(false);
}
}, [open, isVisible, startClose]);
const handleClose = useCallback(() => {
startClose(true);
}, [startClose]);
useEffect(() => {
return () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
}
};
}, []);
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
return ( return (
<div className="modal-overlay" onClick={handleMaskClick}> <div className={overlayClass}>
<div className="modal" style={{ width }} role="dialog" aria-modal="true"> <div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
<IconX size={20} />
</button>
<div className="modal-header"> <div className="modal-header">
<div className="modal-title">{title}</div> <div className="modal-title">{title}</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
<IconX size={18} />
</button>
</div> </div>
<div className="modal-body">{children}</div> <div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>} {footer && <div className="modal-footer">{footer}</div>}

View File

@@ -358,7 +358,7 @@
"models_excluded_hint": "This model is excluded by OAuth" "models_excluded_hint": "This model is excluded by OAuth"
}, },
"vertex_import": { "vertex_import": {
"title": "Vertex AI Credential Import", "title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
"location_label": "Region (optional)", "location_label": "Region (optional)",
"location_placeholder": "us-central1", "location_placeholder": "us-central1",
@@ -604,8 +604,6 @@
"request_log_download_title": "Download Request Log", "request_log_download_title": "Download Request Log",
"request_log_download_confirm": "Download request log for ID {{id}}?", "request_log_download_confirm": "Download request log for ID {{id}}?",
"request_log_download_success": "Request log downloaded successfully", "request_log_download_success": "Request log downloaded successfully",
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
"empty_title": "No Logs Available", "empty_title": "No Logs Available",
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here", "empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"log_content": "Log Content", "log_content": "Log Content",

View File

@@ -358,7 +358,7 @@
"models_excluded_hint": "此模型已被 OAuth 排除" "models_excluded_hint": "此模型已被 OAuth 排除"
}, },
"vertex_import": { "vertex_import": {
"title": "Vertex AI 凭证导入", "title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。", "description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
"location_label": "目标区域 (可选)", "location_label": "目标区域 (可选)",
"location_placeholder": "us-central1", "location_placeholder": "us-central1",
@@ -604,8 +604,6 @@
"request_log_download_title": "下载报文", "request_log_download_title": "下载报文",
"request_log_download_confirm": "是否要下载id为{{id}}的报文?", "request_log_download_confirm": "是否要下载id为{{id}}的报文?",
"request_log_download_success": "报文下载成功", "request_log_download_success": "报文下载成功",
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
"empty_title": "暂无日志记录", "empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里", "empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"log_content": "日志内容", "log_content": "日志内容",

View File

@@ -397,6 +397,79 @@
line-height: 1.5; line-height: 1.5;
} }
// 状态监测栏
.statusBar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 0;
max-width: 280px;
}
.statusBlocks {
display: flex;
gap: 2px;
flex: 1;
min-width: 180px;
}
.statusBlock {
flex: 1;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
}
}
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.statusRate {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.statusRateHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.statusRateMedium {
color: var(--warning-text, #92400e);
background: var(--warning-bg, #fef3c7);
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
}
// 暗色主题适配 // 暗色主题适配
:global([data-theme='dark']) { :global([data-theme='dark']) {
.headerBadge { .headerBadge {
@@ -436,4 +509,23 @@
.apiKeyEntryIndex { .apiKeyEntryIndex {
background: var(--primary-color); background: var(--primary-color);
} }
.statusBlockIdle {
background-color: var(--border-primary, #374151);
}
.statusRateHigh {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.statusRateMedium {
background: rgba(251, 191, 36, 0.2);
color: #fde68a;
}
.statusRateLow {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
} }

View File

@@ -1,5 +1,6 @@
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useInterval } from '@/hooks/useInterval';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -24,7 +25,8 @@ import type {
AmpcodeConfig, AmpcodeConfig,
AmpcodeModelMapping, AmpcodeModelMapping,
} from '@/types'; } from '@/types';
import type { KeyStats, KeyStatBucket } from '@/utils/usage'; import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
import type { ModelInfo } from '@/utils/models'; import type { ModelInfo } from '@/utils/models';
import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers'; import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
@@ -202,6 +204,8 @@ export function AiProvidersPage() {
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]); const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]); const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} }); const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const loadingKeyStatsRef = useRef(false);
const [modal, setModal] = useState<ProviderModal | null>(null); const [modal, setModal] = useState<ProviderModal | null>(null);
@@ -273,13 +277,23 @@ export function AiProvidersPage() {
[openaiForm.modelEntries] [openaiForm.modelEntries]
); );
// 加载 key 统计 // 加载 key 统计和 usage 明细API 层已有60秒超时
const loadKeyStats = useCallback(async () => { const loadKeyStats = useCallback(async () => {
// 防止重复请求
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try { try {
const stats = await usageApi.getKeyStats(); const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats); setKeyStats(stats);
// 收集 usage 明细用于状态栏
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch { } catch {
// 静默失败 // 静默失败
} finally {
loadingKeyStatsRef.current = false;
} }
}, []); }, []);
@@ -311,6 +325,9 @@ export function AiProvidersPage() {
loadKeyStats(); loadKeyStats();
}, [loadKeyStats]); }, [loadKeyStats]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
useEffect(() => { useEffect(() => {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys); if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys); if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
@@ -1090,6 +1107,108 @@ export function AiProvidersPage() {
); );
}; };
// 预计算所有 apiKey 的状态栏数据(避免每次渲染重复计算)
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
// 收集所有需要计算的 apiKey
const allApiKeys = new Set<string>();
geminiKeys.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
codexConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
claudeConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
openaiProviders.forEach((p) => {
(p.apiKeyEntries || []).forEach((e) => e.apiKey && allApiKeys.add(e.apiKey));
});
// 预计算每个 apiKey 的状态数据
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
});
return cache;
}, [usageDetails, geminiKeys, codexConfigs, claudeConfigs, openaiProviders]);
// 预计算 OpenAI 提供商的汇总状态栏数据
const openaiStatusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
openaiProviders.forEach((provider) => {
const allKeys = (provider.apiKeyEntries || []).map((e) => e.apiKey).filter(Boolean);
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
cache.set(provider.name, calculateStatusBarData(filteredDetails));
});
return cache;
}, [usageDetails, openaiProviders]);
// 渲染状态监测栏
const renderStatusBar = (apiKey: string) => {
const statusData = statusBarCache.get(apiKey) || calculateStatusBarData([], apiKey);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
};
// 渲染 OpenAI 提供商的状态栏(汇总多个 apiKey
const renderOpenAIStatusBar = (providerName: string) => {
const statusData = openaiStatusBarCache.get(providerName) || calculateStatusBarData([]);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
};
const renderList = <T,>( const renderList = <T,>(
items: T[], items: T[],
keyField: (item: T) => string, keyField: (item: T) => string,
@@ -1254,6 +1373,8 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure} {t('stats.failure')}: {stats.failure}
</span> </span>
</div> </div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment> </Fragment>
); );
}, },
@@ -1370,6 +1491,8 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure} {t('stats.failure')}: {stats.failure}
</span> </span>
</div> </div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment> </Fragment>
); );
}, },
@@ -1502,6 +1625,8 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure} {t('stats.failure')}: {stats.failure}
</span> </span>
</div> </div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment> </Fragment>
); );
}, },
@@ -1721,6 +1846,8 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure} {t('stats.failure')}: {stats.failure}
</span> </span>
</div> </div>
{/* 状态监测栏(汇总) */}
{renderOpenAIStatusBar(item.name)}
</Fragment> </Fragment>
); );
}, },

View File

@@ -250,6 +250,78 @@
border-color: var(--failure-badge-border, #fca5a5); border-color: var(--failure-badge-border, #fca5a5);
} }
// 状态监测栏
.statusBar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
max-width: 280px;
}
.statusBlocks {
display: flex;
gap: 2px;
flex: 1;
min-width: 180px;
}
.statusBlock {
flex: 1;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
}
}
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.statusRate {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.statusRateHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.statusRateMedium {
color: var(--warning-text, #92400e);
background: var(--warning-bg, #fef3c7);
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
}
.cardActions { .cardActions {
display: flex; display: flex;
gap: $spacing-xs; gap: $spacing-xs;
@@ -350,6 +422,60 @@
flex-shrink: 0; flex-shrink: 0;
} }
// OAuth 排除列表表单:提供商快捷标签
.providerField {
display: flex;
flex-direction: column;
gap: $spacing-xs;
:global(.form-group) {
margin-bottom: 0;
}
}
.providerTagList {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.providerTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
color: var(--text-primary);
background-color: var(--bg-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.providerTagActive {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
&:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
}
// 详情弹窗 // 详情弹窗
.detailContent { .detailContent {
max-height: 400px; max-height: 400px;

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,15 @@
.container { .container {
width: 100%; width: 100%;
height: 100%;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@include mobile {
min-height: auto;
overflow: visible;
}
} }
.pageTitle { .pageTitle {
@@ -53,6 +57,11 @@
gap: $spacing-lg; gap: $spacing-lg;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@include mobile {
gap: $spacing-md;
min-height: auto;
}
} }
.logCard { .logCard {
@@ -61,6 +70,12 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@include mobile {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
}
} }
.toolbar { .toolbar {
@@ -87,6 +102,11 @@
:global(.form-group) { :global(.form-group) {
margin: 0; margin: 0;
} }
@include mobile {
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
} }
.searchWrapper { .searchWrapper {
@@ -161,13 +181,26 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: $radius-md; border-radius: $radius-md;
flex: 0 0 auto; flex: 1 1 auto;
height: 480px; min-height: 280px;
max-height: calc(100vh - 320px);
overflow: auto; overflow: auto;
position: relative; position: relative;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
touch-action: pan-y; touch-action: pan-y;
overscroll-behavior: contain; overscroll-behavior: contain;
@include tablet {
min-height: 240px;
max-height: calc(100vh - 300px);
}
@include mobile {
min-height: 360px;
max-height: 480px;
flex: 0 0 auto;
overflow: auto;
}
} }
.errorPanel { .errorPanel {
@@ -190,6 +223,17 @@
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px; font-size: 12px;
@include mobile {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: $spacing-xs;
> span {
width: 100%;
}
}
} }
.loadMoreCount { .loadMoreCount {
@@ -201,6 +245,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-md; gap: $spacing-md;
@include mobile {
width: 100%;
flex-wrap: wrap;
gap: $spacing-sm;
> span {
white-space: nowrap;
}
}
} }
.logList { .logList {
@@ -227,9 +281,18 @@
background: rgba(59, 130, 246, 0.06); background: rgba(59, 130, 246, 0.06);
} }
@include tablet {
grid-template-columns: 140px 1fr;
gap: $spacing-sm;
padding: 8px 10px;
font-size: 12px;
}
@include mobile { @include mobile {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: $spacing-xs; gap: $spacing-xs;
padding: 8px 10px;
font-size: 11.5px;
} }
} }
@@ -371,12 +434,17 @@
@include mobile { @include mobile {
max-width: 100%; max-width: 100%;
flex-basis: 100%;
} }
} }
.message { .message {
color: var(--text-secondary); color: var(--text-secondary);
word-break: break-word; word-break: break-word;
@include mobile {
flex-basis: 100%;
}
} }
@media (max-height: 820px) { @media (max-height: 820px) {
@@ -402,10 +470,65 @@
} }
.logPanel { .logPanel {
height: 360px; min-height: 200px;
max-height: calc(100vh - 280px);
}
.logRow {
padding: 8px 10px;
font-size: 12px;
} }
.errorPanel { .errorPanel {
height: 360px; height: 360px;
} }
} }
@media (max-height: 600px) {
.pageTitle {
font-size: 20px;
margin-bottom: $spacing-sm;
}
.tabBar {
margin-bottom: $spacing-sm;
}
.tabItem {
padding: 8px 12px;
font-size: 13px;
}
.content {
gap: $spacing-sm;
}
.filters {
margin-bottom: $spacing-sm;
gap: $spacing-sm;
}
.logCard {
padding: $spacing-sm;
}
.logPanel {
min-height: 160px;
max-height: calc(100vh - 220px);
}
.logRow {
padding: 6px 8px;
font-size: 11px;
grid-template-columns: 130px 1fr;
gap: $spacing-xs;
}
.loadMoreBanner {
padding: 6px 10px;
}
.errorPanel {
height: 280px;
}
}

View File

@@ -851,10 +851,6 @@ export function LogsPage() {
</div> </div>
</div> </div>
<div className="hint">
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
</div>
{loading ? ( {loading ? (
<div className="hint">{t('logs.loading')}</div> <div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? ( ) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (

View File

@@ -114,3 +114,25 @@
gap: $spacing-sm; gap: $spacing-sm;
margin-top: $spacing-sm; margin-top: $spacing-sm;
} }
.filePicker {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.fileName {
flex: 1;
min-width: 220px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
}
.fileNamePlaceholder {
color: var(--text-secondary);
}

View File

@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { useNotificationStore, useThemeStore } from '@/stores'; import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import styles from './OAuthPage.module.scss'; import styles from './OAuthPage.module.scss';
import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
@@ -13,6 +14,7 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg'; import iconGemini from '@/assets/icons/gemini.svg';
import iconQwen from '@/assets/icons/qwen.svg'; import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg'; import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
interface ProviderState { interface ProviderState {
url?: string; url?: string;
@@ -36,6 +38,22 @@ interface IFlowCookieState {
errorType?: 'error' | 'warning'; errorType?: 'error' | 'warning';
} }
interface VertexImportResult {
projectId?: string;
email?: string;
location?: string;
authFile?: string;
}
interface VertexImportState {
file?: File;
fileName: string;
location: string;
loading: boolean;
error?: string;
result?: VertexImportResult;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } }, { id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
@@ -57,7 +75,13 @@ export function OAuthPage() {
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>); const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false }); const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const [vertexState, setVertexState] = useState<VertexImportState>({
fileName: '',
location: '',
loading: false
});
const timers = useRef<Record<string, number>>({}); const timers = useRef<Record<string, number>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -216,6 +240,64 @@ export function OAuthPage() {
} }
}; };
const handleVertexFilePick = () => {
vertexFileInputRef.current?.click();
};
const handleVertexFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.json')) {
showNotification(t('vertex_import.file_required'), 'warning');
event.target.value = '';
return;
}
setVertexState((prev) => ({
...prev,
file,
fileName: file.name,
error: undefined,
result: undefined
}));
event.target.value = '';
};
const handleVertexImport = async () => {
if (!vertexState.file) {
const message = t('vertex_import.file_required');
setVertexState((prev) => ({ ...prev, error: message }));
showNotification(message, 'warning');
return;
}
const location = vertexState.location.trim();
setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined }));
try {
const res: VertexImportResponse = await vertexApi.importCredential(
vertexState.file,
location || undefined
);
const result: VertexImportResult = {
projectId: res.project_id,
email: res.email,
location: res.location,
authFile: res['auth-file'] ?? res.auth_file
};
setVertexState((prev) => ({ ...prev, loading: false, result }));
showNotification(t('vertex_import.success'), 'success');
} catch (err: any) {
const message = err?.message || '';
setVertexState((prev) => ({
...prev,
loading: false,
error: message || t('notification.upload_failed')
}));
const notification = message
? `${t('notification.upload_failed')}: ${message}`
: t('notification.upload_failed');
showNotification(notification, 'error');
}
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1> <h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
@@ -328,6 +410,94 @@ export function OAuthPage() {
); );
})} })}
{/* Vertex JSON 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('vertex_import.title')}
</span>
}
extra={
<Button onClick={handleVertexImport} loading={vertexState.loading}>
{t('vertex_import.import_button')}
</Button>
}
>
<div className="hint">{t('vertex_import.description')}</div>
<Input
label={t('vertex_import.location_label')}
hint={t('vertex_import.location_hint')}
value={vertexState.location}
onChange={(e) =>
setVertexState((prev) => ({
...prev,
location: e.target.value
}))
}
placeholder={t('vertex_import.location_placeholder')}
/>
<div className="form-group">
<label>{t('vertex_import.file_label')}</label>
<div className={styles.filePicker}>
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
{t('vertex_import.choose_file')}
</Button>
<div
className={`${styles.fileName} ${
vertexState.fileName ? '' : styles.fileNamePlaceholder
}`.trim()}
>
{vertexState.fileName || t('vertex_import.file_placeholder')}
</div>
</div>
<div className="hint">{t('vertex_import.file_hint')}</div>
<input
ref={vertexFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleVertexFileChange}
/>
</div>
{vertexState.error && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{vertexState.error}
</div>
)}
{vertexState.result && (
<div className="connection-box" style={{ marginTop: 12 }}>
<div className="label">{t('vertex_import.result_title')}</div>
<div className="key-value-list">
{vertexState.result.projectId && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_project')}</span>
<span className="value">{vertexState.result.projectId}</span>
</div>
)}
{vertexState.result.email && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_email')}</span>
<span className="value">{vertexState.result.email}</span>
</div>
)}
{vertexState.result.location && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_location')}</span>
<span className="value">{vertexState.result.location}</span>
</div>
)}
{vertexState.result.authFile && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_file')}</span>
<span className="value">{vertexState.result.authFile}</span>
</div>
)}
</div>
</div>
)}
</Card>
{/* iFlow Cookie 登录 */} {/* iFlow Cookie 登录 */}
<Card <Card
title={ title={

View File

@@ -11,3 +11,4 @@ export * from './logs';
export * from './version'; export * from './version';
export * from './models'; export * from './models';
export * from './transformers'; export * from './transformers';
export * from './vertex';

View File

@@ -0,0 +1,25 @@
/**
* Vertex credential import API
*/
import { apiClient } from './client';
export interface VertexImportResponse {
status: 'ok';
project_id?: string;
email?: string;
location?: string;
'auth-file'?: string;
auth_file?: string;
}
export const vertexApi = {
importCredential: (file: File, location?: string) => {
const formData = new FormData();
formData.append('file', file);
if (location) {
formData.append('location', location);
}
return apiClient.postForm<VertexImportResponse>('/vertex/import', formData);
}
};

View File

@@ -350,6 +350,32 @@ textarea {
justify-content: center; justify-content: center;
z-index: $z-modal; z-index: $z-modal;
padding: $spacing-lg; padding: $spacing-lg;
&.modal-overlay-entering {
animation: modal-overlay-fade-in 0.25s ease-out forwards;
}
&.modal-overlay-closing {
animation: modal-overlay-fade-out 0.35s ease-in forwards;
}
}
@keyframes modal-overlay-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-overlay-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
} }
.modal { .modal {
@@ -361,12 +387,77 @@ textarea {
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
// 关闭按钮中心位置: right 12px + 16px = 28px, top 12px + 16px = 28px
transform-origin: calc(100% - 28px) 28px;
&.modal-entering {
animation: modal-scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
&.modal-closing {
animation: modal-collapse-to-close 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
}
@keyframes modal-scale-in {
from {
opacity: 0;
transform: scale(0.85) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modal-collapse-to-close {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0);
}
}
.modal-close-floating {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
border-radius: $radius-full;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
z-index: 10;
svg {
display: block;
}
&:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
} }
.modal-header { .modal-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg; padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
@@ -375,30 +466,6 @@ textarea {
font-size: 18px; font-size: 18px;
color: var(--text-primary); color: var(--text-primary);
} }
.modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: $radius-md;
transition: color 0.15s ease, background-color 0.15s ease;
svg {
display: block;
}
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
}
} }
.modal-body { .modal-body {

View File

@@ -12,6 +12,13 @@
overflow: hidden; overflow: hidden;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
@media (max-width: $breakpoint-mobile) {
height: auto;
min-height: 100vh;
overflow: visible;
overflow-y: auto;
}
} }
.main-header { .main-header {
@@ -28,6 +35,9 @@
width: 100%; width: 100%;
@media (max-width: $breakpoint-mobile) { @media (max-width: $breakpoint-mobile) {
position: fixed;
left: 0;
right: 0;
padding: $spacing-sm $spacing-md; padding: $spacing-sm $spacing-md;
gap: $spacing-sm; gap: $spacing-sm;
} }
@@ -230,6 +240,17 @@
@supports (height: 100dvh) { @supports (height: 100dvh) {
height: calc(100dvh - var(--header-height)); height: calc(100dvh - var(--header-height));
} }
@media (max-width: $breakpoint-mobile) {
height: auto;
min-height: calc(100vh - var(--header-height));
overflow: visible;
padding-top: var(--header-height);
@supports (min-height: 100dvh) {
min-height: calc(100dvh - var(--header-height));
}
}
} }
.sidebar { .sidebar {
@@ -328,6 +349,16 @@
min-width: 0; min-width: 0;
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
&.content-logs {
overflow: hidden;
@media (max-width: $breakpoint-mobile) {
overflow: visible;
overflow-y: auto;
height: auto;
}
}
} }
.main-content { .main-content {
@@ -338,6 +369,18 @@
gap: $spacing-lg; gap: $spacing-lg;
overflow-x: hidden; overflow-x: hidden;
&.main-content-logs {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
@media (max-width: $breakpoint-mobile) {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
}
}
@media (max-width: $breakpoint-mobile) { @media (max-width: $breakpoint-mobile) {
padding: $spacing-md; padding: $spacing-md;
} }
@@ -354,6 +397,13 @@
font-size: 14px; font-size: 14px;
flex-wrap: wrap; flex-wrap: wrap;
gap: $spacing-sm; gap: $spacing-sm;
.footer-version {
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
}
} }
.login-page { .login-page {

View File

@@ -754,6 +754,103 @@ export function buildChartData(
/** /**
* 依据 usage 数据计算密钥使用统计 * 依据 usage 数据计算密钥使用统计
*/ */
/**
* 状态栏单个格子的状态
*/
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
/**
* 状态栏数据
*/
export interface StatusBarData {
blocks: StatusBlockState[];
successRate: number;
totalSuccess: number;
totalFailure: number;
}
/**
* 计算状态栏数据最近1小时分为20个5分钟的时间块
* 注意20个块 × 5分钟 = 100分钟但我们只使用最近60分钟的数据
* 所以实际只有最后12个块可能有数据前8个块将始终为 idle
*/
export function calculateStatusBarData(
usageDetails: UsageDetail[],
sourceFilter?: string,
authIndexFilter?: number
): StatusBarData {
const BLOCK_COUNT = 20;
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes
const HOUR_MS = 60 * 60 * 1000;
const now = Date.now();
const hourAgo = now - HOUR_MS;
// Initialize blocks
const blockStats: Array<{ success: number; failure: number }> = Array.from(
{ length: BLOCK_COUNT },
() => ({ success: 0, failure: 0 })
);
let totalSuccess = 0;
let totalFailure = 0;
// Filter and bucket the usage details
usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) {
return;
}
// Apply filters if provided
if (sourceFilter !== undefined && detail.source !== sourceFilter) {
return;
}
if (authIndexFilter !== undefined && detail.auth_index !== authIndexFilter) {
return;
}
// Calculate which block this falls into (0 = oldest, 19 = newest)
const ageMs = now - timestamp;
const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS);
if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) {
if (detail.failed) {
blockStats[blockIndex].failure += 1;
totalFailure += 1;
} else {
blockStats[blockIndex].success += 1;
totalSuccess += 1;
}
}
});
// Convert stats to block states
const blocks: StatusBlockState[] = blockStats.map((stat) => {
if (stat.success === 0 && stat.failure === 0) {
return 'idle';
}
if (stat.failure === 0) {
return 'success';
}
if (stat.success === 0) {
return 'failure';
}
return 'mixed';
});
// Calculate success rate
const total = totalSuccess + totalFailure;
const successRate = total > 0 ? (totalSuccess / total) * 100 : 100;
return {
blocks,
successRate,
totalSuccess,
totalFailure
};
}
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats { export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
if (!usageData) { if (!usageData) {
return { bySource: {}, byAuthIndex: {} }; return { bySource: {}, byAuthIndex: {} };