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
35 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useProviderStats = () => {
|
||||
}, [loadUsageStats]);
|
||||
|
||||
useInterval(() => {
|
||||
void refreshKeyStats();
|
||||
void refreshKeyStats().catch(() => {});
|
||||
}, 240_000);
|
||||
|
||||
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading };
|
||||
|
||||
@@ -23,12 +23,14 @@ export const withoutDisableAllModelsRule = (models?: string[]) => {
|
||||
return base;
|
||||
};
|
||||
|
||||
export const parseExcludedModels = (text: string): string[] =>
|
||||
export const parseTextList = (text: string): string[] =>
|
||||
text
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export const parseExcludedModels = parseTextList;
|
||||
|
||||
export const excludedModelsToText = (models?: string[]) =>
|
||||
Array.isArray(models) ? models.join('\n') : '';
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ interface QuotaCardProps<TState extends QuotaStatusState> {
|
||||
quota?: TState;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
i18nPrefix: string;
|
||||
cardIdleMessageKey?: string;
|
||||
cardClassName: string;
|
||||
defaultType: string;
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
@@ -71,6 +72,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
quota,
|
||||
resolvedTheme,
|
||||
i18nPrefix,
|
||||
cardIdleMessageKey,
|
||||
cardClassName,
|
||||
defaultType,
|
||||
renderQuotaItems
|
||||
@@ -88,6 +90,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
quota?.errorStatus,
|
||||
quota?.error || t('common.unknown_error')
|
||||
);
|
||||
const idleMessageKey = cardIdleMessageKey ?? `${i18nPrefix}.idle`;
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const key = `auth_files.filter_${type}`;
|
||||
@@ -117,7 +120,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
{quotaStatus === 'loading' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
|
||||
) : quotaStatus === 'idle' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
|
||||
) : quotaStatus === 'error' ? (
|
||||
<div className={styles.quotaError}>
|
||||
{t(`${i18nPrefix}.load_failed`, {
|
||||
@@ -127,7 +130,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
) : quota ? (
|
||||
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
|
||||
) : (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -271,6 +271,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
quota={quota[item.name]}
|
||||
resolvedTheme={resolvedTheme}
|
||||
i18nPrefix={config.i18nPrefix}
|
||||
cardIdleMessageKey={config.cardIdleMessageKey}
|
||||
cardClassName={config.cardClassName}
|
||||
defaultType={config.type}
|
||||
renderQuotaItems={config.renderQuotaItems}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
export { QuotaSection } from './QuotaSection';
|
||||
export { QuotaCard } from './QuotaCard';
|
||||
export { useQuotaLoader } from './useQuotaLoader';
|
||||
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from './quotaConfigs';
|
||||
export type { QuotaConfig } from './quotaConfigs';
|
||||
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState,
|
||||
KimiQuotaRow,
|
||||
KimiQuotaState,
|
||||
} from '@/types';
|
||||
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import {
|
||||
@@ -34,7 +36,8 @@ import {
|
||||
CODEX_REQUEST_HEADERS,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
GEMINI_CLI_REQUEST_HEADERS,
|
||||
normalizeAuthIndexValue,
|
||||
KIMI_USAGE_URL,
|
||||
KIMI_REQUEST_HEADERS,
|
||||
normalizeGeminiCliModelId,
|
||||
normalizeNumberValue,
|
||||
normalizePlanType,
|
||||
@@ -44,13 +47,16 @@ import {
|
||||
parseClaudeUsagePayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
parseKimiUsagePayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
resolveCodexPlanType,
|
||||
resolveGeminiCliProjectId,
|
||||
formatCodexResetLabel,
|
||||
formatQuotaResetTime,
|
||||
formatKimiResetHint,
|
||||
buildAntigravityQuotaGroups,
|
||||
buildGeminiCliQuotaBuckets,
|
||||
buildKimiQuotaRows,
|
||||
createStatusError,
|
||||
getStatusFromError,
|
||||
isAntigravityFile,
|
||||
@@ -58,14 +64,16 @@ import {
|
||||
isCodexFile,
|
||||
isDisabledAuthFile,
|
||||
isGeminiCliFile,
|
||||
isKimiFile,
|
||||
isRuntimeOnlyAuthFile,
|
||||
} from '@/utils/quota';
|
||||
import { normalizeAuthIndex } from '@/utils/usage';
|
||||
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
|
||||
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
|
||||
@@ -74,16 +82,19 @@ export interface QuotaStore {
|
||||
claudeQuota: Record<string, ClaudeQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
kimiQuota: Record<string, KimiQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
setKimiQuota: (updater: QuotaUpdater<Record<string, KimiQuotaState>>) => void;
|
||||
clearQuotaCache: () => void;
|
||||
}
|
||||
|
||||
export interface QuotaConfig<TState, TData> {
|
||||
type: QuotaType;
|
||||
i18nPrefix: string;
|
||||
cardIdleMessageKey?: string;
|
||||
filterFn: (file: AuthFileItem) => boolean;
|
||||
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
|
||||
storeSelector: (state: QuotaStore) => Record<string, TState>;
|
||||
@@ -135,7 +146,7 @@ const fetchAntigravityQuota = async (
|
||||
t: TFunction
|
||||
): Promise<AntigravityQuotaGroup[]> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||
}
|
||||
@@ -377,7 +388,7 @@ const fetchCodexQuota = async (
|
||||
t: TFunction
|
||||
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('codex_quota.missing_auth_index'));
|
||||
}
|
||||
@@ -419,7 +430,7 @@ const fetchGeminiCliQuota = async (
|
||||
t: TFunction
|
||||
): Promise<GeminiCliQuotaBucketState[]> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
||||
}
|
||||
@@ -667,7 +678,7 @@ const fetchClaudeQuota = async (
|
||||
t: TFunction
|
||||
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('claude_quota.missing_auth_index'));
|
||||
}
|
||||
@@ -758,6 +769,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
|
||||
> = {
|
||||
type: 'claude',
|
||||
i18nPrefix: 'claude_quota',
|
||||
cardIdleMessageKey: 'quota_management.card_idle_hint',
|
||||
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchClaudeQuota,
|
||||
storeSelector: (state) => state.claudeQuota,
|
||||
@@ -784,6 +796,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
|
||||
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||
type: 'antigravity',
|
||||
i18nPrefix: 'antigravity_quota',
|
||||
cardIdleMessageKey: 'quota_management.card_idle_hint',
|
||||
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchAntigravityQuota,
|
||||
storeSelector: (state) => state.antigravityQuota,
|
||||
@@ -809,6 +822,7 @@ export const CODEX_CONFIG: QuotaConfig<
|
||||
> = {
|
||||
type: 'codex',
|
||||
i18nPrefix: 'codex_quota',
|
||||
cardIdleMessageKey: 'quota_management.card_idle_hint',
|
||||
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchCodexQuota,
|
||||
storeSelector: (state) => state.codexQuota,
|
||||
@@ -835,6 +849,7 @@ export const CODEX_CONFIG: QuotaConfig<
|
||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||
type: 'gemini-cli',
|
||||
i18nPrefix: 'gemini_cli_quota',
|
||||
cardIdleMessageKey: 'quota_management.card_idle_hint',
|
||||
filterFn: (file) =>
|
||||
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchGeminiCliQuota,
|
||||
@@ -854,3 +869,107 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
|
||||
gridClassName: styles.geminiCliGrid,
|
||||
renderQuotaItems: renderGeminiCliItems,
|
||||
};
|
||||
|
||||
const fetchKimiQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<KimiQuotaRow[]> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('kimi_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: KIMI_USAGE_URL,
|
||||
header: { ...KIMI_REQUEST_HEADERS },
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseKimiUsagePayload(result.body ?? result.bodyText);
|
||||
if (!payload) {
|
||||
throw new Error(t('kimi_quota.empty_data'));
|
||||
}
|
||||
|
||||
return buildKimiQuotaRows(payload);
|
||||
};
|
||||
|
||||
const renderKimiItems = (
|
||||
quota: KimiQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h } = React;
|
||||
const rows = quota.rows ?? [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return h('div', { className: styleMap.quotaMessage }, t('kimi_quota.empty_data'));
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const limit = row.limit;
|
||||
const used = row.used;
|
||||
const remaining =
|
||||
limit > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((limit - used) / limit) * 100)))
|
||||
: used > 0
|
||||
? 0
|
||||
: null;
|
||||
const percentLabel = remaining === null ? '--' : `${remaining}%`;
|
||||
const rowLabel = row.labelKey
|
||||
? t(row.labelKey, (row.labelParams ?? {}) as Record<string, string | number>)
|
||||
: row.label ?? '';
|
||||
const resetLabel = formatKimiResetHint(t, row.resetHint);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: row.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, rowLabel),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
limit > 0
|
||||
? h('span', { className: styleMap.quotaAmount }, `${used} / ${limit}`)
|
||||
: null,
|
||||
resetLabel
|
||||
? h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
: null
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const KIMI_CONFIG: QuotaConfig<KimiQuotaState, KimiQuotaRow[]> = {
|
||||
type: 'kimi',
|
||||
i18nPrefix: 'kimi_quota',
|
||||
cardIdleMessageKey: 'quota_management.card_idle_hint',
|
||||
filterFn: (file) => isKimiFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchKimiQuota,
|
||||
storeSelector: (state) => state.kimiQuota,
|
||||
storeSetter: 'setKimiQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', rows: [] }),
|
||||
buildSuccessState: (rows) => ({ status: 'success', rows }),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
rows: [],
|
||||
error: message,
|
||||
errorStatus: status,
|
||||
}),
|
||||
cardClassName: styles.kimiCard,
|
||||
controlsClassName: styles.kimiControls,
|
||||
controlClassName: styles.kimiControl,
|
||||
gridClassName: styles.kimiGrid,
|
||||
renderQuotaItems: renderKimiItems,
|
||||
};
|
||||
|
||||
@@ -2,14 +2,15 @@ import { useMemo, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
computeKeyStats,
|
||||
collectUsageDetails,
|
||||
buildCandidateUsageSourceIds,
|
||||
formatCompactNumber
|
||||
formatCompactNumber,
|
||||
normalizeAuthIndex
|
||||
} from '@/utils/usage';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { CredentialInfo } from '@/types/sourceInfo';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
@@ -23,11 +24,6 @@ export interface CredentialStatsCardProps {
|
||||
openaiProviders: OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
interface CredentialInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CredentialRow {
|
||||
key: string;
|
||||
displayName: string;
|
||||
@@ -43,17 +39,6 @@ interface CredentialBucket {
|
||||
failure: number;
|
||||
}
|
||||
|
||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CredentialStatsCard({
|
||||
usage,
|
||||
loading,
|
||||
@@ -78,7 +63,7 @@ export function CredentialStatsCard({
|
||||
const map = new Map<string, CredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const key = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const key = normalizeAuthIndex(rawAuthIndex);
|
||||
if (key) {
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
@@ -96,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;
|
||||
@@ -191,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 = normalizeAuthIndexValue(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;
|
||||
|
||||
@@ -7,11 +7,14 @@ import { Select } from '@/components/ui/Select';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { CredentialInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
collectUsageDetails,
|
||||
extractTotalTokens
|
||||
extractTotalTokens,
|
||||
normalizeAuthIndex
|
||||
} from '@/utils/usage';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const ALL_FILTER = '__all__';
|
||||
@@ -53,40 +56,11 @@ const toNumber = (value: unknown): number => {
|
||||
|
||||
const encodeCsv = (value: string | number): string => {
|
||||
const text = String(value ?? '');
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
const trimmedLeft = text.replace(/^\s+/, '');
|
||||
const safeText = trimmedLeft && /^[=+\-@]/.test(trimmedLeft) ? `'${text}` : text;
|
||||
return `"${safeText.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const downloadFile = (filename: string, content: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
type CredentialInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type SourceInfo = {
|
||||
displayName: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RequestEventsDetailsCard({
|
||||
usage,
|
||||
loading,
|
||||
@@ -113,7 +87,7 @@ export function RequestEventsDetailsCard({
|
||||
if (!Array.isArray(files)) return;
|
||||
const map = new Map<string, CredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const key = normalizeAuthIndexValue(file['auth_index'] ?? file.authIndex);
|
||||
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
|
||||
if (!key) return;
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
@@ -128,83 +102,28 @@ export function RequestEventsDetailsCard({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sourceInfoMap = useMemo(() => {
|
||||
const map = new Map<string, SourceInfo>();
|
||||
|
||||
const registerSource = (sourceId: string, displayName: string, type: string) => {
|
||||
if (!sourceId || !displayName) return;
|
||||
if (map.has(sourceId)) return;
|
||||
map.set(sourceId, { displayName, type });
|
||||
};
|
||||
|
||||
const registerCandidates = (
|
||||
displayName: string,
|
||||
type: string,
|
||||
candidates: string[]
|
||||
) => {
|
||||
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
|
||||
};
|
||||
|
||||
geminiKeys.forEach((config, index) => {
|
||||
const displayName = config.prefix?.trim() || `Gemini #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'gemini',
|
||||
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
claudeConfigs.forEach((config, index) => {
|
||||
const displayName = config.prefix?.trim() || `Claude #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'claude',
|
||||
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
codexConfigs.forEach((config, index) => {
|
||||
const displayName = config.prefix?.trim() || `Codex #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'codex',
|
||||
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
vertexConfigs.forEach((config, index) => {
|
||||
const displayName = config.prefix?.trim() || `Vertex #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
'vertex',
|
||||
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
|
||||
);
|
||||
});
|
||||
|
||||
openaiProviders.forEach((provider, providerIndex) => {
|
||||
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
const candidates = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((sourceId) =>
|
||||
candidates.add(sourceId)
|
||||
);
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((sourceId) =>
|
||||
candidates.add(sourceId)
|
||||
);
|
||||
});
|
||||
registerCandidates(displayName, 'openai', Array.from(candidates));
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]);
|
||||
const sourceInfoMap = useMemo(
|
||||
() =>
|
||||
buildSourceInfoMap({
|
||||
geminiApiKeys: geminiKeys,
|
||||
claudeApiKeys: claudeConfigs,
|
||||
codexApiKeys: codexConfigs,
|
||||
vertexApiKeys: vertexConfigs,
|
||||
openaiCompatibility: openaiProviders,
|
||||
}),
|
||||
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
|
||||
);
|
||||
|
||||
const rows = useMemo<RequestEventRow[]>(() => {
|
||||
const details = collectUsageDetails(usage);
|
||||
|
||||
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;
|
||||
@@ -212,13 +131,9 @@ export function RequestEventsDetailsCard({
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const normalizedAuthIndex = normalizeAuthIndexValue(authIndexRaw);
|
||||
const sourceInfo = sourceInfoMap.get(sourceRaw);
|
||||
const authInfo = normalizedAuthIndex ? authFileMap.get(normalizedAuthIndex) : undefined;
|
||||
const source = sourceInfo?.displayName
|
||||
|| authInfo?.name
|
||||
|| (sourceRaw.startsWith('t:') ? sourceRaw.slice(2) : sourceRaw || '-');
|
||||
const sourceType = sourceInfo?.type || authInfo?.type || '';
|
||||
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
|
||||
@@ -370,7 +285,10 @@ export function RequestEventsDetailsCard({
|
||||
|
||||
const content = [csvHeader.join(','), ...csvRows].join('\n');
|
||||
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
downloadFile(`usage-events-${fileTime}.csv`, content, 'text/csv;charset=utf-8');
|
||||
downloadBlob({
|
||||
filename: `usage-events-${fileTime}.csv`,
|
||||
blob: new Blob([content], { type: 'text/csv;charset=utf-8' })
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
@@ -394,7 +312,10 @@ export function RequestEventsDetailsCard({
|
||||
|
||||
const content = JSON.stringify(payload, null, 2);
|
||||
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
downloadFile(`usage-events-${fileTime}.json`, content, 'application/json;charset=utf-8');
|
||||
downloadBlob({
|
||||
filename: `usage-events-${fileTime}.json`,
|
||||
blob: new Blob([content], { type: 'application/json;charset=utf-8' })
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { USAGE_STATS_STALE_TIME_MS, useNotificationStore, useUsageStatsStore } from '@/stores';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
|
||||
|
||||
export interface UsagePayload {
|
||||
@@ -48,7 +49,7 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
}, [loadUsageStats]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }).catch(() => {});
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [loadUsageStats]);
|
||||
|
||||
@@ -62,13 +63,10 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
? new Date().toISOString()
|
||||
: exportedAt.toISOString();
|
||||
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
||||
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
downloadBlob({
|
||||
filename,
|
||||
blob: new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' })
|
||||
});
|
||||
showNotification(t('usage_stats.export_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
@@ -111,7 +109,15 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
try {
|
||||
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
|
||||
@@ -2,42 +2,34 @@ 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, type KeyStats } from '@/utils/usage';
|
||||
import { calculateStatusBarData, normalizeAuthIndex, type KeyStats } from '@/utils/usage';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import {
|
||||
AUTH_FILE_REFRESH_WARNING_MS,
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
formatModified,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
isRuntimeOnlyAuthFile,
|
||||
normalizeAuthIndexValue,
|
||||
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;
|
||||
@@ -49,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;
|
||||
@@ -66,7 +57,7 @@ const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||
};
|
||||
|
||||
export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
selected,
|
||||
@@ -77,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);
|
||||
@@ -105,69 +95,17 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
? styles.codexCard
|
||||
: quotaType === 'gemini-cli'
|
||||
? styles.geminiCliCard
|
||||
: '';
|
||||
: quotaType === 'kimi'
|
||||
? styles.kimiCard
|
||||
: '';
|
||||
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||
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
|
||||
@@ -181,7 +119,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')}
|
||||
>
|
||||
@@ -193,7 +133,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')}
|
||||
@@ -210,17 +150,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}
|
||||
@@ -239,7 +168,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}>
|
||||
@@ -280,7 +213,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}
|
||||
@@ -314,7 +247,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
|
||||
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from '@/components/quota';
|
||||
import { useNotificationStore, useQuotaStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
@@ -18,6 +18,7 @@ type QuotaState = { status?: string; error?: string; errorStatus?: number } | un
|
||||
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||
if (type === 'codex') return CODEX_CONFIG;
|
||||
if (type === 'kimi') return KIMI_CONFIG;
|
||||
return GEMINI_CLI_CONFIG;
|
||||
};
|
||||
|
||||
@@ -35,12 +36,14 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||
const quota = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'kimi') return state.kimiQuota[file.name] as QuotaState;
|
||||
return state.geminiCliQuota[file.name] as QuotaState;
|
||||
});
|
||||
|
||||
const updateQuotaState = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'kimi') return state.setKimiQuota as unknown as (updater: unknown) => void;
|
||||
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 type { TFunction } from 'i18next';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import {
|
||||
normalizeAuthIndex,
|
||||
normalizeUsageSourceId,
|
||||
type KeyStatBucket,
|
||||
type KeyStats
|
||||
@@ -11,9 +12,9 @@ export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||
|
||||
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
|
||||
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli', 'kimi']);
|
||||
|
||||
export const MIN_CARD_PAGE_SIZE = 3;
|
||||
export const MAX_CARD_PAGE_SIZE = 30;
|
||||
@@ -143,18 +144,6 @@ export const parseDisableCoolingValue = (value: unknown): boolean | undefined =>
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||
export function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
@@ -168,7 +157,7 @@ export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeySt
|
||||
|
||||
// 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index)
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
|
||||
|
||||
// 尝试根据 authIndex 匹配
|
||||
if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNotificationStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
|
||||
|
||||
type DeleteAllOptions = {
|
||||
@@ -316,12 +317,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
downloadBlob({ filename: name, blob });
|
||||
showNotification(t('auth_files.download_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { calculateStatusBarData, type UsageDetail } from '@/utils/usage';
|
||||
import { normalizeAuthIndexValue } from '@/features/authFiles/constants';
|
||||
import { calculateStatusBarData, normalizeAuthIndex, type UsageDetail } from '@/utils/usage';
|
||||
|
||||
export type AuthFileStatusBarData = ReturnType<typeof calculateStatusBarData>;
|
||||
|
||||
@@ -11,11 +10,11 @@ export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails:
|
||||
|
||||
files.forEach((file) => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
|
||||
|
||||
if (authIndexKey) {
|
||||
const filteredDetails = usageDetails.filter((detail) => {
|
||||
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
|
||||
const detailAuthIndex = normalizeAuthIndex(detail.auth_index);
|
||||
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||||
});
|
||||
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||||
@@ -25,4 +24,3 @@ export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails:
|
||||
return cache;
|
||||
}, [files, usageDetails]);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,20 +24,34 @@ export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) {
|
||||
const { showConfirmation } = useNotificationStore();
|
||||
const lastBlockedRef = useRef<string>('');
|
||||
const allowNextNavigationUntilRef = useRef(0);
|
||||
const allowNextNavigationKeyRef = useRef('');
|
||||
const location = useLocation();
|
||||
|
||||
const allowNextNavigation = useCallback(() => {
|
||||
// Allow a short window for programmatic navigations after successful save.
|
||||
// This avoids stale "allow" flags lingering when no navigation happens.
|
||||
// Allow one programmatic navigation after successful save.
|
||||
// A short window is used to avoid stale flags lingering when no navigation happens.
|
||||
allowNextNavigationUntilRef.current = Date.now() + 2_000;
|
||||
allowNextNavigationKeyRef.current = '';
|
||||
}, []);
|
||||
|
||||
const shouldBlockFunction = useCallback<BlockerFunction>(
|
||||
(args) => {
|
||||
if (!enabled) return false;
|
||||
if (allowNextNavigationUntilRef.current > Date.now()) {
|
||||
return false;
|
||||
const now = Date.now();
|
||||
|
||||
if (allowNextNavigationUntilRef.current > now) {
|
||||
const nextKey = `${args.nextLocation.pathname}${args.nextLocation.search}${args.nextLocation.hash}`;
|
||||
if (!allowNextNavigationKeyRef.current) {
|
||||
allowNextNavigationKeyRef.current = nextKey;
|
||||
}
|
||||
if (allowNextNavigationKeyRef.current === nextKey) {
|
||||
return false;
|
||||
}
|
||||
} else if (allowNextNavigationUntilRef.current !== 0) {
|
||||
allowNextNavigationUntilRef.current = 0;
|
||||
allowNextNavigationKeyRef.current = '';
|
||||
}
|
||||
|
||||
return typeof shouldBlock === 'function' ? shouldBlock(args) : shouldBlock;
|
||||
},
|
||||
[enabled, shouldBlock]
|
||||
@@ -48,6 +62,7 @@ export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) {
|
||||
useEffect(() => {
|
||||
if (allowNextNavigationUntilRef.current === 0) return;
|
||||
allowNextNavigationUntilRef.current = 0;
|
||||
allowNextNavigationKeyRef.current = '';
|
||||
}, [location.key]);
|
||||
|
||||
const blockedKey = useMemo(() => {
|
||||
|
||||
@@ -619,6 +619,22 @@
|
||||
"fetch_all": "Fetch All",
|
||||
"remaining_amount": "Remaining {{count}}"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Kimi Quota",
|
||||
"empty_title": "No Kimi Auth Files",
|
||||
"empty_desc": "Upload a Kimi credential to view remaining quota.",
|
||||
"idle": "Click here to refresh quota",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
"empty_data": "No quota data available",
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All",
|
||||
"weekly_limit": "Weekly limit",
|
||||
"limit_window": "{{duration}} limit",
|
||||
"limit_index": "Limit #{{index}}",
|
||||
"reset_hint": "resets in {{hint}}"
|
||||
},
|
||||
"vertex_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.",
|
||||
@@ -778,7 +794,7 @@
|
||||
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
|
||||
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
|
||||
"gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project",
|
||||
"gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account.",
|
||||
"gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account. Enter ALL to fetch all projects.",
|
||||
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
|
||||
"gemini_cli_oauth_url_label": "Authorization URL:",
|
||||
"gemini_cli_open_link": "Open Link",
|
||||
@@ -1023,6 +1039,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",
|
||||
@@ -1229,7 +1246,8 @@
|
||||
"title": "Quota Management",
|
||||
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||
"refresh_files": "Refresh auth files",
|
||||
"refresh_files_and_quota": "Refresh files & quota"
|
||||
"refresh_files_and_quota": "Refresh files & quota",
|
||||
"card_idle_hint": "Use the top \"Refresh files & quota\" button to fetch the latest quota data."
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Management Center Info",
|
||||
|
||||
@@ -622,6 +622,22 @@
|
||||
"fetch_all": "Получить все",
|
||||
"remaining_amount": "Осталось {{count}}"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Квота Kimi",
|
||||
"empty_title": "Файлы авторизации Kimi отсутствуют",
|
||||
"empty_desc": "Загрузите учётные данные Kimi, чтобы увидеть оставшуюся квоту.",
|
||||
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
|
||||
"loading": "Загрузка квоты...",
|
||||
"load_failed": "Не удалось загрузить квоту: {{message}}",
|
||||
"missing_auth_index": "В файле авторизации отсутствует auth_index",
|
||||
"empty_data": "Данные по квоте отсутствуют",
|
||||
"refresh_button": "Обновить квоту",
|
||||
"fetch_all": "Получить все",
|
||||
"weekly_limit": "Недельный лимит",
|
||||
"limit_window": "Лимит {{duration}}",
|
||||
"limit_index": "Лимит #{{index}}",
|
||||
"reset_hint": "сброс через {{hint}}"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Вход с Vertex JSON",
|
||||
"description": "Загрузите JSON ключа сервисного аккаунта Google, чтобы сохранить его как auth-dir/vertex-<project>.json по тем же правилам, что и помощник CLI vertex-import.",
|
||||
@@ -781,7 +797,7 @@
|
||||
"gemini_cli_oauth_hint": "Выполните вход в сервис Google Gemini CLI через OAuth и автоматически получите/сохраните файлы авторизации.",
|
||||
"gemini_cli_project_id_label": "Google Cloud Project ID (необязательно):",
|
||||
"gemini_cli_project_id_placeholder": "Оставьте пустым, чтобы выбрать первый доступный проект автоматически",
|
||||
"gemini_cli_project_id_hint": "Необязательно. Если не указано, система автоматически выберет первый доступный проект вашей учётной записи.",
|
||||
"gemini_cli_project_id_hint": "Необязательно. Если не указано, система автоматически выберет первый доступный проект вашей учётной записи. Введите ALL, чтобы получить все проекты.",
|
||||
"gemini_cli_project_id_required": "Укажите идентификатор проекта Google Cloud.",
|
||||
"gemini_cli_oauth_url_label": "URL авторизации:",
|
||||
"gemini_cli_open_link": "Открыть ссылку",
|
||||
@@ -1026,6 +1042,7 @@
|
||||
"trace_confidence_low": "Низкая",
|
||||
"trace_score": "Оценка {{score}}",
|
||||
"trace_delta_seconds": "Δt {{seconds}}с",
|
||||
"trace_model_matched": "Модель совпала",
|
||||
"trace_request_id": "Request ID",
|
||||
"trace_method": "Метод",
|
||||
"trace_path": "Путь",
|
||||
@@ -1234,7 +1251,8 @@
|
||||
"title": "Управление квотами",
|
||||
"description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex и Gemini CLI.",
|
||||
"refresh_files": "Обновить файлы авторизации",
|
||||
"refresh_files_and_quota": "Обновить файлы и квоты"
|
||||
"refresh_files_and_quota": "Обновить файлы и квоты",
|
||||
"card_idle_hint": "Используйте кнопку «Обновить файлы и квоты» сверху, чтобы загрузить актуальные данные по квотам."
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Информация о центре управления",
|
||||
|
||||
@@ -619,6 +619,22 @@
|
||||
"fetch_all": "获取全部",
|
||||
"remaining_amount": "剩余 {{count}}"
|
||||
},
|
||||
"kimi_quota": {
|
||||
"title": "Kimi 额度",
|
||||
"empty_title": "暂无 Kimi 认证",
|
||||
"empty_desc": "上传 Kimi 认证文件后即可查看额度。",
|
||||
"idle": "点击此处刷新额度",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
"empty_data": "暂无额度数据",
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部",
|
||||
"weekly_limit": "周限额",
|
||||
"limit_window": "{{duration}} 限额",
|
||||
"limit_index": "限额 #{{index}}",
|
||||
"reset_hint": "{{hint}} 后重置"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex JSON 登录",
|
||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||
@@ -778,7 +794,7 @@
|
||||
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
|
||||
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
|
||||
"gemini_cli_project_id_placeholder": "留空将自动选择第一个可用项目",
|
||||
"gemini_cli_project_id_hint": "可选填写项目 ID。如不填写,系统将自动选择您账号下的第一个可用项目。",
|
||||
"gemini_cli_project_id_hint": "可选填写项目 ID。如不填写,系统将自动选择您账号下的第一个可用项目。输入 ALL 可获取全部项目。",
|
||||
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
|
||||
"gemini_cli_oauth_url_label": "授权链接:",
|
||||
"gemini_cli_open_link": "打开链接",
|
||||
@@ -1023,6 +1039,7 @@
|
||||
"trace_confidence_low": "低",
|
||||
"trace_score": "分数 {{score}}",
|
||||
"trace_delta_seconds": "时间差 {{seconds}} 秒",
|
||||
"trace_model_matched": "模型匹配",
|
||||
"trace_request_id": "请求 ID",
|
||||
"trace_method": "请求方法",
|
||||
"trace_path": "路径",
|
||||
@@ -1229,7 +1246,8 @@
|
||||
"title": "配额管理",
|
||||
"description": "集中查看 OAuth 额度与剩余情况",
|
||||
"refresh_files": "刷新认证文件",
|
||||
"refresh_files_and_quota": "刷新认证文件&额度"
|
||||
"refresh_files_and_quota": "刷新认证文件&额度",
|
||||
"card_idle_hint": "请使用顶部“刷新认证文件&额度”按钮获取最新额度。"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "管理中心信息",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuthStore, useClaudeEditDraftStore, useConfigStore, useNotificationS
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
|
||||
@@ -63,19 +63,6 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeClaudeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildClaudeMessagesEndpoint, parseExcludedModels } from '@/components/providers/utils';
|
||||
import { buildClaudeMessagesEndpoint, parseTextList } from '@/components/providers/utils';
|
||||
import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
@@ -71,6 +71,7 @@ export function AiProvidersClaudeEditPage() {
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const lastCloakConfigRef = useRef<typeof form.cloak>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -82,6 +83,11 @@ export function AiProvidersClaudeEditPage() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.cloak) return;
|
||||
lastCloakConfigRef.current = form.cloak;
|
||||
}, [form.cloak]);
|
||||
|
||||
const canSave =
|
||||
!disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTesting;
|
||||
|
||||
@@ -266,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')}
|
||||
@@ -460,16 +484,27 @@ export function AiProvidersClaudeEditPage() {
|
||||
<ToggleSwitch
|
||||
checked={Boolean(form.cloak)}
|
||||
onChange={(enabled) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
cloak: enabled
|
||||
? {
|
||||
mode: (prev.cloak?.mode ?? 'auto').trim() || 'auto',
|
||||
strictMode: prev.cloak?.strictMode ?? false,
|
||||
sensitiveWords: prev.cloak?.sensitiveWords ?? [],
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
setForm((prev) => {
|
||||
if (!enabled) {
|
||||
if (prev.cloak) {
|
||||
lastCloakConfigRef.current = prev.cloak;
|
||||
}
|
||||
return { ...prev, cloak: undefined };
|
||||
}
|
||||
|
||||
const restored = prev.cloak
|
||||
?? lastCloakConfigRef.current
|
||||
?? { mode: 'auto', strictMode: false, sensitiveWords: [] };
|
||||
const mode = String(restored.mode ?? 'auto').trim() || 'auto';
|
||||
return {
|
||||
...prev,
|
||||
cloak: {
|
||||
mode,
|
||||
strictMode: restored.strictMode ?? false,
|
||||
sensitiveWords: restored.sensitiveWords ?? [],
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
disabled={saving || disableControls || isTesting}
|
||||
ariaLabel={t('ai_providers.claude_cloak_toggle_aria')}
|
||||
@@ -527,7 +562,7 @@ export function AiProvidersClaudeEditPage() {
|
||||
placeholder={t('ai_providers.claude_cloak_sensitive_words_placeholder')}
|
||||
value={(form.cloak.sensitiveWords ?? []).join('\n')}
|
||||
onChange={(e) => {
|
||||
const nextWords = parseExcludedModels(e.target.value);
|
||||
const nextWords = parseTextList(e.target.value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
cloak: {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { modelsApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import type { ProviderFormState } from '@/components/providers';
|
||||
@@ -50,19 +50,6 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
@@ -118,6 +105,7 @@ export function AiProvidersCodexEditPage() {
|
||||
const [modelDiscoverySearch, setModelDiscoverySearch] = useState('');
|
||||
const [modelDiscoverySelected, setModelDiscoverySelected] = useState<Set<string>>(new Set());
|
||||
const autoFetchSignatureRef = useRef<string>('');
|
||||
const modelDiscoveryRequestIdRef = useRef(0);
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
@@ -266,6 +254,7 @@ export function AiProvidersCodexEditPage() {
|
||||
);
|
||||
|
||||
const fetchCodexModelDiscovery = useCallback(async () => {
|
||||
const requestId = (modelDiscoveryRequestIdRef.current += 1);
|
||||
setModelDiscoveryFetching(true);
|
||||
setModelDiscoveryError('');
|
||||
|
||||
@@ -280,19 +269,25 @@ export function AiProvidersCodexEditPage() {
|
||||
hasCustomAuthorization ? undefined : apiKey,
|
||||
headerObject
|
||||
);
|
||||
if (modelDiscoveryRequestIdRef.current !== requestId) return;
|
||||
setDiscoveredModels(list);
|
||||
} catch (err: unknown) {
|
||||
if (modelDiscoveryRequestIdRef.current !== requestId) return;
|
||||
setDiscoveredModels([]);
|
||||
const message = getErrorMessage(err);
|
||||
setModelDiscoveryError(`${t('ai_providers.codex_models_fetch_error')}: ${message}`);
|
||||
} finally {
|
||||
setModelDiscoveryFetching(false);
|
||||
if (modelDiscoveryRequestIdRef.current === requestId) {
|
||||
setModelDiscoveryFetching(false);
|
||||
}
|
||||
}
|
||||
}, [form.apiKey, form.baseUrl, form.headers, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDiscoveryOpen) {
|
||||
autoFetchSignatureRef.current = '';
|
||||
modelDiscoveryRequestIdRef.current += 1;
|
||||
setModelDiscoveryFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -422,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;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { modelsApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
@@ -45,19 +45,6 @@ const stripGeminiModelResourceName = (value: string) => {
|
||||
return String(value ?? '').trim().replace(/^\/?models\//i, '');
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = stripGeminiModelResourceName(entry?.name ?? '').trim();
|
||||
@@ -112,6 +99,7 @@ export function AiProvidersGeminiEditPage() {
|
||||
const [modelDiscoverySearch, setModelDiscoverySearch] = useState('');
|
||||
const [modelDiscoverySelected, setModelDiscoverySelected] = useState<Set<string>>(new Set());
|
||||
const autoFetchSignatureRef = useRef<string>('');
|
||||
const modelDiscoveryRequestIdRef = useRef(0);
|
||||
|
||||
const hasIndexParam = typeof params.index === 'string';
|
||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||
@@ -244,6 +232,7 @@ export function AiProvidersGeminiEditPage() {
|
||||
);
|
||||
|
||||
const fetchGeminiModelDiscovery = useCallback(async () => {
|
||||
const requestId = (modelDiscoveryRequestIdRef.current += 1);
|
||||
setModelDiscoveryFetching(true);
|
||||
setModelDiscoveryError('');
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
@@ -253,8 +242,10 @@ export function AiProvidersGeminiEditPage() {
|
||||
form.apiKey.trim() || undefined,
|
||||
headerObject
|
||||
);
|
||||
if (modelDiscoveryRequestIdRef.current !== requestId) return;
|
||||
setDiscoveredModels(list);
|
||||
} catch (err: unknown) {
|
||||
if (modelDiscoveryRequestIdRef.current !== requestId) return;
|
||||
setDiscoveredModels([]);
|
||||
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||
const hasCustomXGoogApiKey = Object.keys(headerObject).some(
|
||||
@@ -271,13 +262,17 @@ export function AiProvidersGeminiEditPage() {
|
||||
: '';
|
||||
setModelDiscoveryError(`${t('ai_providers.gemini_models_fetch_error')}: ${message}${diag}`);
|
||||
} finally {
|
||||
setModelDiscoveryFetching(false);
|
||||
if (modelDiscoveryRequestIdRef.current === requestId) {
|
||||
setModelDiscoveryFetching(false);
|
||||
}
|
||||
}
|
||||
}, [form.apiKey, form.baseUrl, form.headers, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDiscoveryOpen) {
|
||||
autoFetchSignatureRef.current = '';
|
||||
modelDiscoveryRequestIdRef.current += 1;
|
||||
setModelDiscoveryFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -416,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')}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftS
|
||||
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
@@ -63,19 +63,6 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: ModelEntry[]) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function AiProvidersPage() {
|
||||
if (hasMounted.current) return;
|
||||
hasMounted.current = true;
|
||||
loadConfigs();
|
||||
loadKeyStats();
|
||||
void loadKeyStats().catch(() => {});
|
||||
}, [loadConfigs, loadKeyStats]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import type { VertexFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
@@ -35,19 +35,6 @@ const parseIndexParam = (value: string | undefined) => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
@@ -60,6 +47,8 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
|
||||
const buildVertexSignature = (form: VertexFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
@@ -208,6 +197,10 @@ export function AiProvidersVertexEditPage() {
|
||||
try {
|
||||
const payload: ProviderKeyConfig = {
|
||||
apiKey: form.apiKey.trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: undefined,
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl,
|
||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||
@@ -265,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')}
|
||||
|
||||
@@ -322,6 +322,10 @@
|
||||
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
|
||||
}
|
||||
|
||||
.kimiCard {
|
||||
background-image: linear-gradient(180deg, rgba(255, 244, 229, 0.2), rgba(255, 244, 229, 0));
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -605,59 +609,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);
|
||||
@@ -212,18 +211,17 @@ export function AuthFilesPage() {
|
||||
useEffect(() => {
|
||||
if (!isCurrentLayer) return;
|
||||
loadFiles();
|
||||
loadKeyStats();
|
||||
void loadKeyStats().catch(() => {});
|
||||
loadExcluded();
|
||||
loadModelAlias();
|
||||
}, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
void refreshKeyStats();
|
||||
void refreshKeyStats().catch(() => {});
|
||||
},
|
||||
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);
|
||||
|
||||
+131
-883
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,13 @@
|
||||
margin-bottom: 0;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
:global(.input:disabled) {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.formItem {
|
||||
|
||||
@@ -153,8 +153,14 @@ export function OAuthPage() {
|
||||
};
|
||||
|
||||
const startAuth = async (provider: OAuthProvider) => {
|
||||
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
|
||||
// 项目 ID 现在是可选的,如果不输入将自动选择第一个可用项目
|
||||
const geminiState = provider === 'gemini-cli' ? states[provider] : undefined;
|
||||
const rawProjectId = provider === 'gemini-cli' ? (geminiState?.projectId || '').trim() : '';
|
||||
const projectId = rawProjectId
|
||||
? rawProjectId.toUpperCase() === 'ALL'
|
||||
? 'ALL'
|
||||
: rawProjectId
|
||||
: undefined;
|
||||
// 项目 ID 可选:留空自动选择第一个可用项目;输入 ALL 获取全部项目
|
||||
if (provider === 'gemini-cli') {
|
||||
updateProviderState(provider, { projectIdError: undefined });
|
||||
}
|
||||
@@ -367,6 +373,7 @@ export function OAuthPage() {
|
||||
hint={t('auth_login.gemini_cli_project_id_hint')}
|
||||
value={state.projectId || ''}
|
||||
error={state.projectIdError}
|
||||
disabled={Boolean(state.polling)}
|
||||
onChange={(e) =>
|
||||
updateProviderState(provider.id, {
|
||||
projectId: e.target.value,
|
||||
|
||||
@@ -105,7 +105,8 @@
|
||||
.antigravityGrid,
|
||||
.claudeGrid,
|
||||
.codexGrid,
|
||||
.geminiCliGrid {
|
||||
.geminiCliGrid,
|
||||
.kimiGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
@@ -118,7 +119,8 @@
|
||||
.antigravityControls,
|
||||
.claudeControls,
|
||||
.codexControls,
|
||||
.geminiCliControls {
|
||||
.geminiCliControls,
|
||||
.kimiControls {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
@@ -129,7 +131,8 @@
|
||||
.antigravityControl,
|
||||
.claudeControl,
|
||||
.codexControl,
|
||||
.geminiCliControl {
|
||||
.geminiCliControl,
|
||||
.kimiControl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@@ -172,6 +175,12 @@
|
||||
rgba(231, 239, 255, 0));
|
||||
}
|
||||
|
||||
.kimiCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(255, 244, 229, 0.2),
|
||||
rgba(255, 244, 229, 0));
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ANTIGRAVITY_CONFIG,
|
||||
CLAUDE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CLI_CONFIG
|
||||
GEMINI_CLI_CONFIG,
|
||||
KIMI_CONFIG
|
||||
} from '@/components/quota';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import styles from './QuotaPage.module.scss';
|
||||
@@ -94,6 +95,12 @@ export function QuotaPage() {
|
||||
loading={loading}
|
||||
disabled={disableControls}
|
||||
/>
|
||||
<QuotaSection
|
||||
config={KIMI_CONFIG}
|
||||
files={files}
|
||||
loading={loading}
|
||||
disabled={disableControls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -266,7 +268,7 @@ export function UsagePage() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadUsage}
|
||||
onClick={() => void loadUsage().catch(() => {})}
|
||||
disabled={loading || exporting || importing}
|
||||
>
|
||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
||||
@@ -293,6 +295,7 @@ export function UsagePage() {
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
modelPrices={modelPrices}
|
||||
nowMs={nowMs}
|
||||
sparklines={{
|
||||
requests: requestsSparkline,
|
||||
tokens: tokensSparkline,
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { HTTP_METHODS, type HttpMethod, type LogLevel, type ParsedLogLine } from './logTypes';
|
||||
|
||||
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
||||
|
||||
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
||||
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
|
||||
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
||||
const LOG_LATENCY_REGEX =
|
||||
/\b(?:\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))(?:\s*\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))*\b/i;
|
||||
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
||||
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
||||
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
|
||||
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
||||
const GIN_TIMESTAMP_SEGMENT_REGEX =
|
||||
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
|
||||
|
||||
const HTTP_STATUS_PATTERNS: RegExp[] = [
|
||||
/\|\s*([1-5]\d{2})\s*\|/,
|
||||
/\b([1-5]\d{2})\s*-/,
|
||||
new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`),
|
||||
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
|
||||
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
|
||||
];
|
||||
|
||||
const detectHttpStatusCode = (text: string): number | undefined => {
|
||||
for (const pattern of HTTP_STATUS_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (!match) continue;
|
||||
const code = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(code)) continue;
|
||||
if (code >= 100 && code <= 599) return code;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractIp = (text: string): string | undefined => {
|
||||
const ipv4Match = text.match(LOG_IPV4_REGEX);
|
||||
if (ipv4Match) return ipv4Match[0];
|
||||
|
||||
const ipv6Match = text.match(LOG_IPV6_REGEX);
|
||||
if (!ipv6Match) return undefined;
|
||||
|
||||
const candidate = ipv6Match[0];
|
||||
|
||||
// Avoid treating time strings like "12:34:56" as IPv6 addresses.
|
||||
if (LOG_TIME_OF_DAY_REGEX.test(candidate)) return undefined;
|
||||
|
||||
// If no compression marker is present, a valid IPv6 address must contain 8 hextets.
|
||||
if (!candidate.includes('::') && candidate.split(':').length !== 8) return undefined;
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const normalizeTimestampToSeconds = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
|
||||
if (!match) return trimmed;
|
||||
return `${match[1]} ${match[2]}`;
|
||||
};
|
||||
|
||||
const extractLatency = (text: string): string | undefined => {
|
||||
const match = text.match(LOG_LATENCY_REGEX);
|
||||
if (!match) return undefined;
|
||||
return match[0].replace(/\s+/g, '');
|
||||
};
|
||||
|
||||
const extractLogLevel = (value: string): LogLevel | undefined => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'warning') return 'warn';
|
||||
if (normalized === 'warn') return 'warn';
|
||||
if (normalized === 'info') return 'info';
|
||||
if (normalized === 'error') return 'error';
|
||||
if (normalized === 'fatal') return 'fatal';
|
||||
if (normalized === 'debug') return 'debug';
|
||||
if (normalized === 'trace') return 'trace';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const inferLogLevel = (line: string): LogLevel | undefined => {
|
||||
const lowered = line.toLowerCase();
|
||||
if (/\bfatal\b/.test(lowered)) return 'fatal';
|
||||
if (/\berror\b/.test(lowered)) return 'error';
|
||||
if (/\bwarn(?:ing)?\b/.test(lowered) || line.includes('警告')) return 'warn';
|
||||
if (/\binfo\b/.test(lowered)) return 'info';
|
||||
if (/\bdebug\b/.test(lowered)) return 'debug';
|
||||
if (/\btrace\b/.test(lowered)) return 'trace';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => {
|
||||
const match = text.match(HTTP_METHOD_REGEX);
|
||||
if (!match) return {};
|
||||
|
||||
const method = match[1] as HttpMethod;
|
||||
const index = match.index ?? 0;
|
||||
const after = text.slice(index + match[0].length).trim();
|
||||
const path = after ? after.split(/\s+/)[0] : undefined;
|
||||
return { method, path };
|
||||
};
|
||||
|
||||
export const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
let remaining = raw.trim();
|
||||
|
||||
let timestamp: string | undefined;
|
||||
const tsMatch = remaining.match(LOG_TIMESTAMP_REGEX);
|
||||
if (tsMatch) {
|
||||
timestamp = tsMatch[1];
|
||||
remaining = remaining.slice(tsMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let requestId: string | undefined;
|
||||
const requestIdMatch = remaining.match(/^\[([a-f0-9]{8}|--------)\]\s*/i);
|
||||
if (requestIdMatch) {
|
||||
const id = requestIdMatch[1];
|
||||
if (!/^-+$/.test(id)) {
|
||||
requestId = id;
|
||||
}
|
||||
remaining = remaining.slice(requestIdMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let level: LogLevel | undefined;
|
||||
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
|
||||
if (lvlMatch) {
|
||||
level = extractLogLevel(lvlMatch[1]);
|
||||
remaining = remaining.slice(lvlMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let source: string | undefined;
|
||||
const sourceMatch = remaining.match(LOG_SOURCE_REGEX);
|
||||
if (sourceMatch) {
|
||||
source = sourceMatch[1];
|
||||
remaining = remaining.slice(sourceMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let statusCode: number | undefined;
|
||||
let latency: string | undefined;
|
||||
let ip: string | undefined;
|
||||
let method: HttpMethod | undefined;
|
||||
let path: string | undefined;
|
||||
let message = remaining;
|
||||
|
||||
if (remaining.includes('|')) {
|
||||
const segments = remaining
|
||||
.split('|')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
const consumed = new Set<number>();
|
||||
|
||||
const ginIndex = segments.findIndex((segment) => GIN_TIMESTAMP_SEGMENT_REGEX.test(segment));
|
||||
if (ginIndex >= 0) {
|
||||
const match = segments[ginIndex].match(GIN_TIMESTAMP_SEGMENT_REGEX);
|
||||
if (match) {
|
||||
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
|
||||
const normalizedGin = normalizeTimestampToSeconds(ginTimestamp);
|
||||
const normalizedParsed = timestamp ? normalizeTimestampToSeconds(timestamp) : undefined;
|
||||
|
||||
if (!timestamp) {
|
||||
timestamp = ginTimestamp;
|
||||
consumed.add(ginIndex);
|
||||
} else if (normalizedParsed === normalizedGin) {
|
||||
consumed.add(ginIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// request id (8-char hex or dashes)
|
||||
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
|
||||
if (requestIdIndex >= 0) {
|
||||
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
if (!/^-+$/.test(id)) {
|
||||
requestId = id;
|
||||
}
|
||||
consumed.add(requestIdIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// status code
|
||||
const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
|
||||
if (statusIndex >= 0) {
|
||||
const match = segments[statusIndex].match(/^(\d{3})$/);
|
||||
if (match) {
|
||||
const code = Number.parseInt(match[1], 10);
|
||||
if (code >= 100 && code <= 599) {
|
||||
statusCode = code;
|
||||
consumed.add(statusIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// latency
|
||||
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
|
||||
if (latencyIndex >= 0) {
|
||||
const extracted = extractLatency(segments[latencyIndex]);
|
||||
if (extracted) {
|
||||
latency = extracted;
|
||||
consumed.add(latencyIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// ip
|
||||
const ipIndex = segments.findIndex((segment) => Boolean(extractIp(segment)));
|
||||
if (ipIndex >= 0) {
|
||||
const extracted = extractIp(segments[ipIndex]);
|
||||
if (extracted) {
|
||||
ip = extracted;
|
||||
consumed.add(ipIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// method + path
|
||||
const methodIndex = segments.findIndex((segment) => {
|
||||
const { method: parsedMethod } = extractHttpMethodAndPath(segment);
|
||||
return Boolean(parsedMethod);
|
||||
});
|
||||
if (methodIndex >= 0) {
|
||||
const parsed = extractHttpMethodAndPath(segments[methodIndex]);
|
||||
method = parsed.method;
|
||||
path = parsed.path;
|
||||
consumed.add(methodIndex);
|
||||
}
|
||||
|
||||
// source (e.g. [gin_logger.go:94])
|
||||
const sourceIndex = segments.findIndex((segment) => LOG_SOURCE_REGEX.test(segment));
|
||||
if (sourceIndex >= 0) {
|
||||
const match = segments[sourceIndex].match(LOG_SOURCE_REGEX);
|
||||
if (match) {
|
||||
source = match[1];
|
||||
consumed.add(sourceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
|
||||
} else {
|
||||
statusCode = detectHttpStatusCode(remaining);
|
||||
|
||||
const extracted = extractLatency(remaining);
|
||||
if (extracted) latency = extracted;
|
||||
|
||||
ip = extractIp(remaining);
|
||||
|
||||
const parsed = extractHttpMethodAndPath(remaining);
|
||||
method = parsed.method;
|
||||
path = parsed.path;
|
||||
}
|
||||
|
||||
if (!level) level = inferLogLevel(raw);
|
||||
|
||||
if (message) {
|
||||
const match = message.match(GIN_TIMESTAMP_SEGMENT_REGEX);
|
||||
if (match) {
|
||||
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
|
||||
if (!timestamp) timestamp = ginTimestamp;
|
||||
if (normalizeTimestampToSeconds(timestamp) === normalizeTimestampToSeconds(ginTimestamp)) {
|
||||
message = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
timestamp,
|
||||
level,
|
||||
source,
|
||||
requestId,
|
||||
statusCode,
|
||||
latency,
|
||||
ip,
|
||||
method,
|
||||
path,
|
||||
message,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
||||
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
|
||||
export const STATUS_GROUPS = ['2xx', '3xx', '4xx', '5xx'] as const;
|
||||
export type StatusGroup = (typeof STATUS_GROUPS)[number];
|
||||
|
||||
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||
|
||||
export type LogState = {
|
||||
buffer: string[];
|
||||
visibleFrom: number;
|
||||
};
|
||||
|
||||
export type ParsedLogLine = {
|
||||
raw: string;
|
||||
timestamp?: string;
|
||||
level?: LogLevel;
|
||||
source?: string;
|
||||
requestId?: string;
|
||||
statusCode?: number;
|
||||
latency?: string;
|
||||
ip?: string;
|
||||
method?: HttpMethod;
|
||||
path?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const resolveStatusGroup = (statusCode?: number): StatusGroup | undefined => {
|
||||
if (typeof statusCode !== 'number') return undefined;
|
||||
if (statusCode >= 200 && statusCode < 300) return '2xx';
|
||||
if (statusCode >= 300 && statusCode < 400) return '3xx';
|
||||
if (statusCode >= 400 && statusCode < 500) return '4xx';
|
||||
if (statusCode >= 500 && statusCode < 600) return '5xx';
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { HttpMethod, ParsedLogLine, StatusGroup } from './logTypes';
|
||||
import { resolveStatusGroup } from './logTypes';
|
||||
|
||||
const PATH_FILTER_LIMIT = 12;
|
||||
|
||||
interface UseLogFiltersOptions {
|
||||
parsedLines: ParsedLogLine[];
|
||||
}
|
||||
|
||||
interface UseLogFiltersReturn {
|
||||
methodFilters: HttpMethod[];
|
||||
statusFilters: StatusGroup[];
|
||||
pathFilters: string[];
|
||||
methodFilterSet: Set<HttpMethod>;
|
||||
statusFilterSet: Set<StatusGroup>;
|
||||
pathFilterSet: Set<string>;
|
||||
hasStructuredFilters: boolean;
|
||||
methodCounts: Partial<Record<HttpMethod, number>>;
|
||||
statusCounts: Partial<Record<StatusGroup, number>>;
|
||||
pathOptions: Array<{ path: string; count: number }>;
|
||||
toggleMethodFilter: (method: HttpMethod) => void;
|
||||
toggleStatusFilter: (group: StatusGroup) => void;
|
||||
togglePathFilter: (path: string) => void;
|
||||
clearStructuredFilters: () => void;
|
||||
}
|
||||
|
||||
export function useLogFilters(options: UseLogFiltersOptions): UseLogFiltersReturn {
|
||||
const { parsedLines } = options;
|
||||
|
||||
const [methodFilters, setMethodFilters] = useState<HttpMethod[]>([]);
|
||||
const [statusFilters, setStatusFilters] = useState<StatusGroup[]>([]);
|
||||
const [pathFilters, setPathFilters] = useState<string[]>([]);
|
||||
|
||||
const methodFilterSet = useMemo(() => new Set(methodFilters), [methodFilters]);
|
||||
const statusFilterSet = useMemo(() => new Set(statusFilters), [statusFilters]);
|
||||
const pathFilterSet = useMemo(() => new Set(pathFilters), [pathFilters]);
|
||||
const hasStructuredFilters =
|
||||
methodFilters.length > 0 || statusFilters.length > 0 || pathFilters.length > 0;
|
||||
|
||||
const methodCounts = useMemo(() => {
|
||||
const counts: Partial<Record<HttpMethod, number>> = {};
|
||||
parsedLines.forEach((line) => {
|
||||
if (!line.method) return;
|
||||
counts[line.method] = (counts[line.method] ?? 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [parsedLines]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Partial<Record<StatusGroup, number>> = {};
|
||||
parsedLines.forEach((line) => {
|
||||
const statusGroup = resolveStatusGroup(line.statusCode);
|
||||
if (!statusGroup) return;
|
||||
counts[statusGroup] = (counts[statusGroup] ?? 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [parsedLines]);
|
||||
|
||||
const pathOptions = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
parsedLines.forEach((line) => {
|
||||
if (!line.path) return;
|
||||
counts.set(line.path, (counts.get(line.path) ?? 0) + 1);
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, PATH_FILTER_LIMIT)
|
||||
.map(([path, count]) => ({ path, count }));
|
||||
}, [parsedLines]);
|
||||
|
||||
useEffect(() => {
|
||||
const validPathSet = new Set(pathOptions.map((item) => item.path));
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPathFilters((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const next = prev.filter((path) => validPathSet.has(path));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [pathOptions]);
|
||||
|
||||
const toggleMethodFilter = (method: HttpMethod) => {
|
||||
setMethodFilters((prev) =>
|
||||
prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleStatusFilter = (group: StatusGroup) => {
|
||||
setStatusFilters((prev) =>
|
||||
prev.includes(group) ? prev.filter((item) => item !== group) : [...prev, group]
|
||||
);
|
||||
};
|
||||
|
||||
const togglePathFilter = (path: string) => {
|
||||
setPathFilters((prev) =>
|
||||
prev.includes(path) ? prev.filter((item) => item !== path) : [...prev, path]
|
||||
);
|
||||
};
|
||||
|
||||
const clearStructuredFilters = () => {
|
||||
setMethodFilters([]);
|
||||
setStatusFilters([]);
|
||||
setPathFilters([]);
|
||||
};
|
||||
|
||||
return {
|
||||
methodFilters,
|
||||
statusFilters,
|
||||
pathFilters,
|
||||
methodFilterSet,
|
||||
statusFilterSet,
|
||||
pathFilterSet,
|
||||
hasStructuredFilters,
|
||||
methodCounts,
|
||||
statusCounts,
|
||||
pathOptions,
|
||||
toggleMethodFilter,
|
||||
toggleStatusFilter,
|
||||
togglePathFilter,
|
||||
clearStructuredFilters,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction, UIEvent } from 'react';
|
||||
import type { LogState } from './logTypes';
|
||||
|
||||
const LOAD_MORE_LINES = 200;
|
||||
const LOAD_MORE_THRESHOLD_PX = 72;
|
||||
|
||||
export const isNearBottom = (node: HTMLDivElement | null) => {
|
||||
if (!node) return true;
|
||||
const threshold = 24;
|
||||
return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
|
||||
};
|
||||
|
||||
interface UseLogScrollerOptions {
|
||||
logState: LogState;
|
||||
setLogState: Dispatch<SetStateAction<LogState>>;
|
||||
loading: boolean;
|
||||
isSearching: boolean;
|
||||
filteredLineCount: number;
|
||||
hasStructuredFilters: boolean;
|
||||
showRawLogs: boolean;
|
||||
}
|
||||
|
||||
interface UseLogScrollerReturn {
|
||||
logViewerRef: RefObject<HTMLDivElement | null>;
|
||||
canLoadMore: boolean;
|
||||
handleLogScroll: (e: UIEvent<HTMLDivElement>) => void;
|
||||
scrollToBottom: () => void;
|
||||
requestScrollToBottom: () => void;
|
||||
}
|
||||
|
||||
export function useLogScroller(options: UseLogScrollerOptions): UseLogScrollerReturn {
|
||||
const {
|
||||
logState,
|
||||
setLogState,
|
||||
loading,
|
||||
isSearching,
|
||||
filteredLineCount,
|
||||
hasStructuredFilters,
|
||||
showRawLogs,
|
||||
} = options;
|
||||
|
||||
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||
|
||||
const canLoadMore = !isSearching && logState.visibleFrom > 0;
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
node.scrollTop = node.scrollHeight;
|
||||
}, []);
|
||||
|
||||
const requestScrollToBottom = useCallback(() => {
|
||||
pendingScrollToBottomRef.current = true;
|
||||
}, []);
|
||||
|
||||
const prependVisibleLines = useCallback(() => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
if (pendingPrependScrollRef.current) return;
|
||||
if (isSearching) return;
|
||||
|
||||
setLogState((prev) => {
|
||||
if (prev.visibleFrom <= 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
pendingPrependScrollRef.current = {
|
||||
scrollHeight: node.scrollHeight,
|
||||
scrollTop: node.scrollTop,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
|
||||
};
|
||||
});
|
||||
}, [isSearching, setLogState]);
|
||||
|
||||
const handleLogScroll = useCallback(
|
||||
(_e: UIEvent<HTMLDivElement>) => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
if (isSearching) return;
|
||||
if (!canLoadMore) return;
|
||||
if (pendingPrependScrollRef.current) return;
|
||||
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
|
||||
|
||||
prependVisibleLines();
|
||||
},
|
||||
[canLoadMore, isSearching, prependVisibleLines]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = logViewerRef.current;
|
||||
const pending = pendingPrependScrollRef.current;
|
||||
if (!node || !pending) return;
|
||||
|
||||
const delta = node.scrollHeight - pending.scrollHeight;
|
||||
node.scrollTop = pending.scrollTop + delta;
|
||||
pendingPrependScrollRef.current = null;
|
||||
}, [logState.visibleFrom]);
|
||||
|
||||
const tryAutoLoadMoreUntilScrollable = useCallback(() => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
if (!canLoadMore) return;
|
||||
if (isSearching) return;
|
||||
if (pendingPrependScrollRef.current) return;
|
||||
|
||||
const hasVerticalOverflow = node.scrollHeight > node.clientHeight + 1;
|
||||
if (hasVerticalOverflow) return;
|
||||
|
||||
prependVisibleLines();
|
||||
}, [canLoadMore, isSearching, prependVisibleLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
tryAutoLoadMoreUntilScrollable();
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [
|
||||
filteredLineCount,
|
||||
hasStructuredFilters,
|
||||
loading,
|
||||
logState.visibleFrom,
|
||||
showRawLogs,
|
||||
tryAutoLoadMoreUntilScrollable,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
tryAutoLoadMoreUntilScrollable();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [tryAutoLoadMoreUntilScrollable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingScrollToBottomRef.current) return;
|
||||
if (loading) return;
|
||||
if (!logViewerRef.current) return;
|
||||
|
||||
scrollToBottom();
|
||||
pendingScrollToBottomRef.current = false;
|
||||
}, [loading, logState.buffer, logState.visibleFrom, scrollToBottom]);
|
||||
|
||||
return {
|
||||
logViewerRef,
|
||||
canLoadMore,
|
||||
handleLogScroll,
|
||||
scrollToBottom,
|
||||
requestScrollToBottom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
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';
|
||||
import {
|
||||
collectUsageDetailsWithEndpoint,
|
||||
normalizeAuthIndex,
|
||||
type UsageDetailWithEndpoint
|
||||
} from '@/utils/usage';
|
||||
import type { ParsedLogLine } from './logTypes';
|
||||
|
||||
export type TraceCandidate = {
|
||||
detail: UsageDetailWithEndpoint;
|
||||
modelMatched: boolean;
|
||||
timeDeltaMs: number | null;
|
||||
};
|
||||
|
||||
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'];
|
||||
|
||||
const normalizeTracePath = (value?: string) =>
|
||||
String(value ?? '')
|
||||
.replace(/^"+|"+$/g, '')
|
||||
.split('?')[0]
|
||||
.trim();
|
||||
|
||||
const normalizeTraceablePath = (value?: string): string => {
|
||||
const normalized = normalizeTracePath(value);
|
||||
if (!normalized || normalized === '/') return normalized;
|
||||
return normalized.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
export const isTraceableRequestPath = (value?: string): boolean => {
|
||||
const normalizedPath = normalizeTraceablePath(value);
|
||||
if (!normalizedPath) return false;
|
||||
if (TRACEABLE_EXACT_PATHS.has(normalizedPath)) return true;
|
||||
return TRACEABLE_PREFIX_PATHS.some((prefix) => normalizedPath.startsWith(prefix));
|
||||
};
|
||||
|
||||
const MODEL_EXTRACT_REGEX = /\bmodel[=:]\s*"?([a-zA-Z0-9._\-/]+)"?/i;
|
||||
|
||||
const extractModelFromMessage = (message?: string): string | undefined => {
|
||||
if (!message) return undefined;
|
||||
const match = message.match(MODEL_EXTRACT_REGEX);
|
||||
return match?.[1] || undefined;
|
||||
};
|
||||
|
||||
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 => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
if (typeof err !== 'object' || err === null) return '';
|
||||
if (!('message' in err)) return '';
|
||||
|
||||
const message = (err as { message?: unknown }).message;
|
||||
return typeof message === 'string' ? message : '';
|
||||
};
|
||||
|
||||
interface UseTraceResolverOptions {
|
||||
traceScopeKey: string;
|
||||
connectionStatus: string;
|
||||
config: Config | null;
|
||||
requestLogDownloading: boolean;
|
||||
}
|
||||
|
||||
interface UseTraceResolverReturn {
|
||||
traceLogLine: ParsedLogLine | null;
|
||||
traceLoading: boolean;
|
||||
traceError: string;
|
||||
traceCandidates: TraceCandidate[];
|
||||
resolveTraceSourceInfo: (sourceRaw: string, authIndex: unknown) => SourceInfo;
|
||||
loadTraceUsageDetails: () => Promise<void>;
|
||||
refreshTraceUsageDetails: () => Promise<void>;
|
||||
openTraceModal: (line: ParsedLogLine) => void;
|
||||
closeTraceModal: () => void;
|
||||
}
|
||||
|
||||
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 [traceAuthFileMap, setTraceAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
|
||||
const [traceLoading, setTraceLoading] = useState(false);
|
||||
const [traceError, setTraceError] = useState('');
|
||||
|
||||
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 loadTraceUsageDetailsInternal = useCallback(async (forceUsage: boolean) => {
|
||||
if (traceScopeKeyRef.current !== traceScopeKey) {
|
||||
traceScopeKeyRef.current = traceScopeKey;
|
||||
traceAuthLoadedAtRef.current = 0;
|
||||
setTraceAuthFileMap(new Map());
|
||||
setTraceError('');
|
||||
}
|
||||
|
||||
if (traceLoading) return;
|
||||
|
||||
const now = Date.now();
|
||||
const authFresh =
|
||||
traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_AUTH_CACHE_MS;
|
||||
|
||||
setTraceLoading(true);
|
||||
setTraceError('');
|
||||
try {
|
||||
const [, authFilesResponse] = await Promise.all([
|
||||
loadUsageStats({
|
||||
force: forceUsage,
|
||||
staleTimeMs: USAGE_STATS_STALE_TIME_MS
|
||||
}),
|
||||
authFresh ? Promise.resolve(null) : authFilesApi.list().catch(() => null)
|
||||
]);
|
||||
|
||||
if (authFilesResponse !== null) {
|
||||
const files = Array.isArray(authFilesResponse)
|
||||
? authFilesResponse
|
||||
: (authFilesResponse as { files?: AuthFileItem[] })?.files;
|
||||
if (Array.isArray(files)) {
|
||||
const map = new Map<string, CredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
|
||||
if (!key) return;
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString()
|
||||
});
|
||||
});
|
||||
setTraceAuthFileMap(map);
|
||||
traceAuthLoadedAtRef.current = Date.now();
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setTraceError(getErrorMessage(err) || t('logs.trace_usage_load_error'));
|
||||
} finally {
|
||||
setTraceLoading(false);
|
||||
}
|
||||
}, [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;
|
||||
traceAuthLoadedAtRef.current = 0;
|
||||
setTraceAuthFileMap(new Map());
|
||||
setTraceLoading(false);
|
||||
setTraceError('');
|
||||
}
|
||||
}, [connectionStatus, traceScopeKey]);
|
||||
|
||||
const traceCandidates = useMemo(() => {
|
||||
if (!traceLogLine) return [];
|
||||
|
||||
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(
|
||||
(sourceRaw: string, authIndex: unknown): SourceInfo =>
|
||||
resolveSourceDisplay(sourceRaw, authIndex, traceSourceInfoMap, traceAuthFileMap),
|
||||
[traceAuthFileMap, traceSourceInfoMap]
|
||||
);
|
||||
|
||||
const openTraceModal = useCallback(
|
||||
(line: ParsedLogLine) => {
|
||||
if (!isTraceableRequestPath(line.path)) return;
|
||||
setTraceError('');
|
||||
setTraceLogLine(line);
|
||||
void loadTraceUsageDetails();
|
||||
},
|
||||
[loadTraceUsageDetails]
|
||||
);
|
||||
|
||||
const closeTraceModal = useCallback(() => {
|
||||
if (requestLogDownloading) return;
|
||||
setTraceLogLine(null);
|
||||
}, [requestLogDownloading]);
|
||||
|
||||
return {
|
||||
traceLogLine,
|
||||
traceLoading,
|
||||
traceError,
|
||||
traceCandidates,
|
||||
resolveTraceSourceInfo,
|
||||
loadTraceUsageDetails,
|
||||
refreshTraceUsageDetails,
|
||||
openTraceModal,
|
||||
closeTraceModal
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
|
||||
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState, KimiQuotaState } from '@/types';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
@@ -12,10 +12,12 @@ interface QuotaStoreState {
|
||||
claudeQuota: Record<string, ClaudeQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
kimiQuota: Record<string, KimiQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
setKimiQuota: (updater: QuotaUpdater<Record<string, KimiQuotaState>>) => void;
|
||||
clearQuotaCache: () => void;
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
|
||||
claudeQuota: {},
|
||||
codexQuota: {},
|
||||
geminiCliQuota: {},
|
||||
kimiQuota: {},
|
||||
setAntigravityQuota: (updater) =>
|
||||
set((state) => ({
|
||||
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
|
||||
@@ -47,11 +50,16 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
|
||||
set((state) => ({
|
||||
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
|
||||
})),
|
||||
setKimiQuota: (updater) =>
|
||||
set((state) => ({
|
||||
kimiQuota: resolveUpdater(updater, state.kimiQuota)
|
||||
})),
|
||||
clearQuotaCache: () =>
|
||||
set({
|
||||
antigravityQuota: {},
|
||||
claudeQuota: {},
|
||||
codexQuota: {},
|
||||
geminiCliQuota: {}
|
||||
geminiCliQuota: {},
|
||||
kimiQuota: {}
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -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(),
|
||||
@@ -109,11 +110,13 @@ export const useUsageStatsStore = create<UsageStatsState>((set, get) => ({
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== usageRequestToken) return;
|
||||
const message = getErrorMessage(error);
|
||||
set({
|
||||
loading: false,
|
||||
error: getErrorMessage(error),
|
||||
error: message,
|
||||
scopeKey
|
||||
});
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
if (inFlightUsageRequest?.id === requestId) {
|
||||
inFlightUsageRequest = null;
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from './oauth';
|
||||
export * from './usage';
|
||||
export * from './log';
|
||||
export * from './quota';
|
||||
export * from './sourceInfo';
|
||||
|
||||
@@ -197,3 +197,64 @@ export interface CodexQuotaState {
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
// Kimi API payload types
|
||||
export interface KimiUsageDetail {
|
||||
used?: number;
|
||||
limit?: number;
|
||||
remaining?: number;
|
||||
name?: string;
|
||||
title?: string;
|
||||
resetAt?: string;
|
||||
reset_at?: string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
resetIn?: number;
|
||||
reset_in?: number;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface KimiLimitWindow {
|
||||
duration?: number;
|
||||
timeUnit?: string;
|
||||
}
|
||||
|
||||
export interface KimiLimitItem {
|
||||
name?: string;
|
||||
title?: string;
|
||||
scope?: string;
|
||||
detail?: KimiUsageDetail;
|
||||
window?: KimiLimitWindow;
|
||||
used?: number;
|
||||
limit?: number;
|
||||
remaining?: number;
|
||||
duration?: number;
|
||||
timeUnit?: string;
|
||||
resetAt?: string;
|
||||
reset_at?: string;
|
||||
resetIn?: number;
|
||||
reset_in?: number;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface KimiUsagePayload {
|
||||
usage?: KimiUsageDetail;
|
||||
limits?: KimiLimitItem[];
|
||||
}
|
||||
|
||||
export interface KimiQuotaRow {
|
||||
id: string;
|
||||
label?: string;
|
||||
labelKey?: string;
|
||||
labelParams?: Record<string, string | number>;
|
||||
used: number;
|
||||
limit: number;
|
||||
resetHint?: string;
|
||||
}
|
||||
|
||||
export interface KimiQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
rows: KimiQuotaRow[];
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type SourceInfo = {
|
||||
displayName: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type CredentialInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export type DownloadBlobOptions = {
|
||||
filename: string;
|
||||
blob: Blob;
|
||||
revokeDelayMs?: number;
|
||||
};
|
||||
|
||||
export function downloadBlob({ filename, blob, revokeDelayMs = 1000 }: DownloadBlobOptions) {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.rel = 'noopener';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.URL.revokeObjectURL(url);
|
||||
link.remove();
|
||||
}, revokeDelayMs);
|
||||
}
|
||||
|
||||
@@ -37,3 +37,16 @@ export function headersToEntries(headers?: Record<string, string | undefined | n
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => ({ key, value: String(value) }));
|
||||
}
|
||||
|
||||
export const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
|
||||
(entries ?? [])
|
||||
.map((entry) => ({
|
||||
key: String(entry?.key ?? '').trim(),
|
||||
value: String(entry?.value ?? '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.key || entry.value)
|
||||
.sort((a, b) => {
|
||||
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
|
||||
if (byKey !== 0) return byKey;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
AntigravityModelsPayload,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
KimiUsagePayload,
|
||||
KimiUsageDetail,
|
||||
KimiLimitItem,
|
||||
KimiLimitWindow,
|
||||
KimiQuotaRow,
|
||||
} from '@/types';
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_GROUPS,
|
||||
@@ -260,3 +265,156 @@ export function buildAntigravityQuotaGroups(
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function toInt(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type KimiRowLabel = Pick<KimiQuotaRow, 'label' | 'labelKey' | 'labelParams'>;
|
||||
|
||||
function kimiResetHint(data: Record<string, unknown>): string | undefined {
|
||||
const absoluteKeys = ['reset_at', 'resetAt', 'reset_time', 'resetTime'];
|
||||
for (const key of absoluteKeys) {
|
||||
const raw = data[key];
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
const truncated = raw.replace(/(\.\d{6})\d+/, '$1');
|
||||
const date = new Date(truncated);
|
||||
if (Number.isNaN(date.getTime())) continue;
|
||||
const now = Date.now();
|
||||
const delta = date.getTime() - now;
|
||||
if (delta <= 0) return undefined;
|
||||
const totalMinutes = Math.floor(delta / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return '<1m';
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relativeKeys = ['reset_in', 'resetIn', 'ttl'];
|
||||
for (const key of relativeKeys) {
|
||||
const raw = toInt(data[key]);
|
||||
if (raw !== null && raw > 0) {
|
||||
const hours = Math.floor(raw / 3600);
|
||||
const minutes = Math.floor((raw % 3600) / 60);
|
||||
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return '<1m';
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function kimiDurationToken(duration: number, rawTimeUnit: unknown): string {
|
||||
const unit = typeof rawTimeUnit === 'string' ? rawTimeUnit.trim().toUpperCase() : '';
|
||||
if (unit === 'MINUTES') {
|
||||
return duration % 60 === 0 ? `${duration / 60}h` : `${duration}m`;
|
||||
}
|
||||
if (unit === 'HOURS') return `${duration}h`;
|
||||
if (unit === 'DAYS') return `${duration}d`;
|
||||
return `${duration}s`;
|
||||
}
|
||||
|
||||
function kimiLimitLabel(
|
||||
item: KimiLimitItem,
|
||||
detail: KimiUsageDetail | KimiLimitItem,
|
||||
window: KimiLimitWindow,
|
||||
index: number
|
||||
): KimiRowLabel {
|
||||
for (const key of ['name', 'title', 'scope'] as const) {
|
||||
const val = (item as Record<string, unknown>)[key] ?? (detail as Record<string, unknown>)[key];
|
||||
if (typeof val === 'string' && val.trim()) return { label: val.trim() };
|
||||
}
|
||||
|
||||
const duration =
|
||||
toInt(window.duration) ??
|
||||
toInt((item as Record<string, unknown>).duration) ??
|
||||
toInt((detail as Record<string, unknown>).duration);
|
||||
const timeUnit =
|
||||
(window as Record<string, unknown>).timeUnit ??
|
||||
(item as Record<string, unknown>).timeUnit ??
|
||||
(detail as Record<string, unknown>).timeUnit;
|
||||
|
||||
if (duration !== null && duration > 0) {
|
||||
return {
|
||||
labelKey: 'kimi_quota.limit_window',
|
||||
labelParams: {
|
||||
duration: kimiDurationToken(duration, timeUnit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
labelKey: 'kimi_quota.limit_index',
|
||||
labelParams: {
|
||||
index: index + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toKimiUsageRow(
|
||||
data: Record<string, unknown>,
|
||||
fallbackLabel: KimiRowLabel
|
||||
): (KimiRowLabel & { used: number; limit: number; resetHint?: string }) | null {
|
||||
const limit = toInt(data.limit);
|
||||
let used = toInt(data.used);
|
||||
if (used === null) {
|
||||
const remaining = toInt(data.remaining);
|
||||
if (remaining !== null && limit !== null) {
|
||||
used = limit - remaining;
|
||||
}
|
||||
}
|
||||
if (used === null && limit === null) return null;
|
||||
const explicitLabel =
|
||||
(typeof data.name === 'string' && data.name.trim()) ||
|
||||
(typeof data.title === 'string' && data.title.trim());
|
||||
const label = explicitLabel ? { label: explicitLabel } : fallbackLabel;
|
||||
return {
|
||||
...label,
|
||||
used: used ?? 0,
|
||||
limit: limit ?? 0,
|
||||
resetHint: kimiResetHint(data),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKimiQuotaRows(payload: KimiUsagePayload): KimiQuotaRow[] {
|
||||
const rows: KimiQuotaRow[] = [];
|
||||
|
||||
const usage = payload.usage;
|
||||
if (usage && typeof usage === 'object') {
|
||||
const summary = toKimiUsageRow(usage as Record<string, unknown>, {
|
||||
labelKey: 'kimi_quota.weekly_limit',
|
||||
});
|
||||
if (summary) {
|
||||
rows.push({ id: 'summary', ...summary });
|
||||
}
|
||||
}
|
||||
|
||||
const limits = payload.limits;
|
||||
if (Array.isArray(limits)) {
|
||||
limits.forEach((item, idx) => {
|
||||
const detail = (item.detail && typeof item.detail === 'object' ? item.detail : item) as KimiUsageDetail | KimiLimitItem;
|
||||
const window = (item.window && typeof item.window === 'object' ? item.window : {}) as KimiLimitWindow;
|
||||
const fallbackLabel = kimiLimitLabel(item, detail, window, idx);
|
||||
const row = toKimiUsageRow(detail as Record<string, unknown>, fallbackLabel);
|
||||
if (row) {
|
||||
rows.push({ id: `limit-${idx}`, ...row });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||
dark: { bg: '#e65100', text: '#ffb74d' },
|
||||
},
|
||||
kimi: {
|
||||
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||
dark: { bg: '#7c4a03', text: '#ffd591' },
|
||||
},
|
||||
antigravity: {
|
||||
light: { bg: '#e0f7fa', text: '#006064' },
|
||||
dark: { bg: '#004d40', text: '#80deea' },
|
||||
@@ -134,8 +138,8 @@ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'gemini-pro-series',
|
||||
label: 'Gemini Pro Series',
|
||||
preferredModelId: 'gemini-3-1-pro-preview',
|
||||
modelIds: ['gemini-3-1-pro-preview', 'gemini-3-pro-preview', 'gemini-2.5-pro'],
|
||||
preferredModelId: 'gemini-3.1-pro-preview',
|
||||
modelIds: ['gemini-3.1-pro-preview', 'gemini-3-pro-preview', 'gemini-2.5-pro'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -178,3 +182,10 @@ export const CODEX_REQUEST_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
|
||||
};
|
||||
|
||||
// Kimi API configuration
|
||||
export const KIMI_USAGE_URL = 'https://api.kimi.com/coding/v1/usages';
|
||||
|
||||
export const KIMI_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Formatting functions for quota display.
|
||||
*/
|
||||
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { CodexUsageWindow } from '@/types';
|
||||
import { normalizeNumberValue } from './parsers';
|
||||
|
||||
@@ -66,3 +67,8 @@ export function getStatusFromError(err: unknown): number | undefined {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatKimiResetHint(t: TFunction, hint?: string): string {
|
||||
if (!hint) return '';
|
||||
return t('kimi_quota.reset_hint', { hint });
|
||||
}
|
||||
|
||||
+20
-12
@@ -2,20 +2,11 @@
|
||||
* Normalization and parsing functions for quota data.
|
||||
*/
|
||||
|
||||
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
|
||||
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
|
||||
import { normalizeAuthIndex } from '@/utils/usage';
|
||||
|
||||
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
|
||||
|
||||
export function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export { normalizeAuthIndex };
|
||||
|
||||
export function normalizeStringValue(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
@@ -179,3 +170,20 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseKimiUsagePayload(payload: unknown): KimiUsagePayload | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as KimiUsagePayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as KimiUsagePayload;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ export function isGeminiCliFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'gemini-cli';
|
||||
}
|
||||
|
||||
export function isKimiFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'kimi';
|
||||
}
|
||||
|
||||
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
|
||||
import { buildCandidateUsageSourceIds, normalizeAuthIndex } from '@/utils/usage';
|
||||
|
||||
export interface SourceInfoMapInput {
|
||||
geminiApiKeys?: GeminiKeyConfig[];
|
||||
claudeApiKeys?: ProviderKeyConfig[];
|
||||
codexApiKeys?: ProviderKeyConfig[];
|
||||
vertexApiKeys?: ProviderKeyConfig[];
|
||||
openaiCompatibility?: OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, SourceInfo> {
|
||||
const map = new Map<string, SourceInfo>();
|
||||
|
||||
const registerSource = (sourceId: string, displayName: string, type: string) => {
|
||||
if (!sourceId || !displayName || map.has(sourceId)) return;
|
||||
map.set(sourceId, { displayName, type });
|
||||
};
|
||||
|
||||
const registerCandidates = (displayName: string, type: string, candidates: string[]) => {
|
||||
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
|
||||
};
|
||||
|
||||
const providers: Array<{
|
||||
items: Array<{ apiKey?: string; prefix?: string }>;
|
||||
type: string;
|
||||
label: string;
|
||||
}> = [
|
||||
{ items: input.geminiApiKeys || [], type: 'gemini', label: 'Gemini' },
|
||||
{ items: input.claudeApiKeys || [], type: 'claude', label: 'Claude' },
|
||||
{ items: input.codexApiKeys || [], type: 'codex', label: 'Codex' },
|
||||
{ items: input.vertexApiKeys || [], type: 'vertex', label: 'Vertex' },
|
||||
];
|
||||
|
||||
providers.forEach(({ items, type, label }) => {
|
||||
items.forEach((item, index) => {
|
||||
const displayName = item.prefix?.trim() || `${label} #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
type,
|
||||
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// OpenAI 特殊处理:多 apiKeyEntries
|
||||
(input.openaiCompatibility || []).forEach((provider, providerIndex) => {
|
||||
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
const candidates = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||
});
|
||||
registerCandidates(displayName, 'openai', Array.from(candidates));
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function resolveSourceDisplay(
|
||||
sourceRaw: string,
|
||||
authIndex: unknown,
|
||||
sourceInfoMap: Map<string, SourceInfo>,
|
||||
authFileMap: Map<string, CredentialInfo>
|
||||
): SourceInfo {
|
||||
const source = sourceRaw.trim();
|
||||
const matched = sourceInfoMap.get(source);
|
||||
if (matched) return matched;
|
||||
|
||||
const authIndexKey = normalizeAuthIndex(authIndex);
|
||||
if (authIndexKey) {
|
||||
const authInfo = authFileMap.get(authIndexKey);
|
||||
if (authInfo) {
|
||||
return { displayName: authInfo.name || authIndexKey, type: authInfo.type };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: source.startsWith('t:') ? source.slice(2) : source || '-',
|
||||
type: '',
|
||||
};
|
||||
}
|
||||
+167
-61
@@ -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;
|
||||
}
|
||||
|
||||
@@ -222,7 +213,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
} as T;
|
||||
}
|
||||
|
||||
const normalizeAuthIndex = (value: unknown) => {
|
||||
export const normalizeAuthIndex = (value: unknown) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
@@ -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