mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 10:14:34 +00:00
Merge remote-tracking branch 'origin/dev' into codex/fix-logs-filtering-to-show-dashboard-audits
This commit is contained in:
commit
3d9636351e
156 files changed
+4169
-625
No files matched your search
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="180" height="180" viewBox="0 0 180 180"><defs><clipPath id="master_svg0_381_5952"><rect x="0" y="0" width="180" height="180" rx="40"/></clipPath><clipPath id="master_svg1_381_5952/255_01913"><rect x="18" y="18" width="144" height="144" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_381_5952)"><rect x="0" y="0" width="180" height="180" rx="40" fill="#111113" fill-opacity="1"/><g clip-path="url(#master_svg1_381_5952/255_01913)"><g><path d="M147.59971253967285,101.41062772064208L147.59971253967285,81.42072772064209C147.59971253967285,70.06382772064208,142.65821253967283,60.949927720642094,134.05571253967287,56.41322772064209L89.62531253967285,32.98532772064209L89.40251253967284,33.41064472064209L89.17971253967285,33.83596072064209C97.45821253967284,38.20051772064209,102.20761253967285,47.00562772064209,102.20761253967285,57.992927720642086L102.20761253967285,77.98282772064209C102.20761253967285,78.58532772064208,102.19241253967286,79.21822772064209,102.16201253967284,79.8511277206421C102.15691253967285,79.9726277206421,102.14681253967285,80.0942277206421,102.13671253967286,80.22072772064209L102.11641253967285,80.51952772064209C102.09111253967285,80.9195277206421,102.06581253967286,81.3245277206421,102.02531253967285,81.72452772064209C102.01011253967286,81.88662772064208,101.98981253967285,82.05372772064209,101.96961253967285,82.21572772064209L101.92401253967284,82.59542772064209C101.88351253967285,82.92962772064209,101.84811253967285,83.2587277206421,101.79741253967285,83.59292772064208C101.77211253967286,83.78022772064209,101.74171253967285,83.9625277206421,101.71141253967285,84.1499277206421L101.66581253967286,84.4385277206421C101.61011253967285,84.77772772064209,101.55441253967285,85.11692772064208,101.48861253967286,85.45622772064209C101.39241253967285,85.9625277206421,101.29111253967285,86.46382772064209,101.17971253967285,86.9650277206421L101.14431253967285,87.11192772064209C101.03291253967285,87.59802772064208,100.91141253967285,88.07902772064209,100.77971253967286,88.5600277206421L100.66331253967286,88.98532772064209C100.52661253967285,89.47142772064208,100.38481253967285,89.95742772064209,100.23291253967285,90.4435277206421L100.09621253967285,90.86382772064209C99.92401253967284,91.39542772064209,99.74171253967285,91.92712772064209,99.54931253967285,92.45372772064209C99.49871253967285,92.59542772064209,99.44301253967285,92.7321277206421,99.38731253967285,92.87392772064209L99.30121253967286,93.10182772064209C99.14431253967285,93.5068277206421,98.98731253967284,93.9170277206421,98.82021253967285,94.3220277206421C98.72401253967286,94.5600277206421,98.61771253967285,94.7980277206421,98.51641253967284,95.03592772064209L98.48101253967286,95.12202772064208C98.34931253967285,95.43092772064209,98.21261253967285,95.7397277206421,98.07081253967286,96.04352772064209C97.94931253967286,96.31702772064209,97.81771253967285,96.58532772064208,97.69111253967284,96.8537277206421C97.54431253967286,97.1575277206421,97.40251253967284,97.45622772064209,97.25061253967286,97.75492772064209C97.10881253967285,98.0334277206421,96.96701253967285,98.30682772064209,96.82021253967285,98.58022772064209C96.67841253967285,98.84862772064209,96.53161253967285,99.1220277206421,96.38481253967285,99.3904277206421C96.23291253967285,99.66382772064209,96.07591253967286,99.93722772064208,95.91891253967285,100.2106277206421C95.76701253967286,100.4739277206421,95.61521253967285,100.7423277206421,95.45311253967284,101.00052772064208C95.29111253967285,101.27392772064209,95.12401253967286,101.54232772064209,94.95191253967286,101.81062772064209C94.78981253967285,102.0689277206421,94.63291253967284,102.32202772064208,94.46581253967285,102.57522772064209C94.29361253967285,102.84352772064209,94.11641253967285,103.10182772064209,93.91391253967285,103.40562772064209C93.75191253967284,103.64352772064208,93.58981253967285,103.88152772064208,93.42781253967286,104.11442772064208C93.25571253967286,104.3575277206421,93.08351253967285,104.59542772064209,92.88101253967285,104.87392772064209C92.69361253967284,105.13212772064209,92.50631253967285,105.3853277206421,92.31391253967286,105.63852772064209C92.12651253967286,105.8866277206421,91.93921253967285,106.1246277206421,91.74681253967285,106.36762772064209C91.54931253967285,106.61572772064208,91.35191253967285,106.86892772064209,91.15441253967285,107.11192772064209C90.98221253967284,107.32462772064208,90.80501253967284,107.53222772064208,90.62781253967285,107.7397277206421L90.59241253967285,107.78032772064209C90.35951253967285,108.0587277206421,90.12151253967285,108.33212772064209,89.87841253967285,108.60562772064209C89.68601253967284,108.82332772064208,89.49361253967285,109.0360277206421,89.30121253967286,109.24862772064209C89.05821253967285,109.51702772064209,88.81521253967284,109.77522772064209,88.56711253967285,110.0385277206421C88.37971253967285,110.2359277206421,88.18731253967286,110.4Line truncated
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -29,7 +29,7 @@ import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from
|
||||
import { useKiroImport } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||
import type { CliproxyProviderCatalog } from '@/lib/api-client';
|
||||
import type { CliproxyProviderCatalog, OAuthAccount } from '@/lib/api-client';
|
||||
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
|
||||
import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist';
|
||||
import {
|
||||
@@ -58,6 +58,8 @@ interface AddAccountDialogProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
catalog?: CliproxyProviderCatalog;
|
||||
/** Existing account to reauthenticate instead of adding a new account. */
|
||||
account?: OAuthAccount;
|
||||
/** Whether this is the first account being added (shows different toast message) */
|
||||
isFirstAccount?: boolean;
|
||||
}
|
||||
@@ -77,6 +79,7 @@ export function AddAccountDialog({
|
||||
provider,
|
||||
displayName,
|
||||
catalog,
|
||||
account,
|
||||
isFirstAccount = false,
|
||||
}: AddAccountDialogProps) {
|
||||
const [nickname, setNickname] = useState('');
|
||||
@@ -127,6 +130,8 @@ export function AddAccountDialog({
|
||||
const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim();
|
||||
const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim();
|
||||
const errorMessage = localError || authFlow.error;
|
||||
const isReauth = Boolean(account);
|
||||
const accountLabel = account?.email || account?.nickname || account?.id || displayName;
|
||||
|
||||
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
|
||||
const response = await fetch('/api/settings/auth/antigravity-risk');
|
||||
@@ -325,7 +330,8 @@ export function AddAccountDialog({
|
||||
}
|
||||
wasAuthenticatingRef.current = true;
|
||||
authFlow.startAuth(provider, {
|
||||
nickname: nicknameTrimmed || undefined,
|
||||
accountId: account?.id,
|
||||
nickname: account ? account.nickname : nicknameTrimmed || undefined,
|
||||
kiroMethod: isKiro ? kiroAuthMethod : undefined,
|
||||
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
|
||||
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
|
||||
@@ -385,13 +391,19 @@ export function AddAccountDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="min-w-0 px-6 pt-6 pr-12">
|
||||
<DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isReauth
|
||||
? `Reauthenticate ${displayName}`
|
||||
: t('addAccountDialog.title', { displayName })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isKiro
|
||||
? t('addAccountDialog.descKiro')
|
||||
: isDeviceCode
|
||||
? t('addAccountDialog.descDeviceCode')
|
||||
: t('addAccountDialog.descOauth')}
|
||||
{isReauth
|
||||
? `Refresh credentials for ${accountLabel}.`
|
||||
: isKiro
|
||||
? t('addAccountDialog.descKiro')
|
||||
: isDeviceCode
|
||||
? t('addAccountDialog.descDeviceCode')
|
||||
: t('addAccountDialog.descOauth')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -600,7 +612,16 @@ export function AddAccountDialog({
|
||||
)}
|
||||
|
||||
{/* Nickname input - only show before auth starts */}
|
||||
{!showAuthUI && (
|
||||
{!showAuthUI && isReauth && (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className="font-medium">{accountLabel}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This sign-in updates the selected account instead of creating another account.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAuthUI && !isReauth && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -784,7 +805,7 @@ export function AddAccountDialog({
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
{t('addAccountDialog.authenticate')}
|
||||
{isReauth ? 'Reauthenticate' : t('addAccountDialog.authenticate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,10 @@ function clearLocalControlPanelSession(): void {
|
||||
window.localStorage.removeItem(CONTROL_PANEL_LOGIN_FLAG_KEY);
|
||||
}
|
||||
|
||||
function clearPersistedLocalControlPanelSecret(): void {
|
||||
window.localStorage.removeItem(CONTROL_PANEL_MANAGEMENT_KEY);
|
||||
}
|
||||
|
||||
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
|
||||
const { t } = useTranslation();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -162,7 +166,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
clearLocalControlPanelSession();
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
clearLocalControlPanelSession();
|
||||
};
|
||||
}, [isRemote]);
|
||||
@@ -266,9 +276,15 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
const handleIframeLoad = () => {
|
||||
setLoadedFrameKey(iframeKey);
|
||||
postRemoteAutoLoginCredentials();
|
||||
if (!isRemote) {
|
||||
clearPersistedLocalControlPanelSecret();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (!isRemote) {
|
||||
clearLocalControlPanelSession();
|
||||
}
|
||||
setLoadedFrameKey(null);
|
||||
setIframeRevision((value) => value + 1);
|
||||
setError(null);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
@@ -87,6 +88,7 @@ export function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
onReauth,
|
||||
onPauseToggle,
|
||||
isRemoving,
|
||||
isPausingAccount,
|
||||
@@ -167,6 +169,12 @@ export function AccountItem({
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onReauth && (
|
||||
<DropdownMenuItem onClick={onReauth}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reauthenticate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
|
||||
interface AccountsSectionProps {
|
||||
accounts: OAuthAccount[];
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -48,6 +49,7 @@ interface AccountsSectionProps {
|
||||
export function AccountsSection({
|
||||
accounts,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -175,6 +177,7 @@ export function AccountsSection({
|
||||
account={account}
|
||||
onSetDefault={() => onSetDefault(account.id)}
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
onReauth={onReauthAccount ? () => onReauthAccount(account) : undefined}
|
||||
onPauseToggle={
|
||||
onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export function ProviderEditor({
|
||||
defaultTarget,
|
||||
topNotice,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -92,6 +93,7 @@ export function ProviderEditor({
|
||||
qwen: ['alibaba', 'qwen'],
|
||||
iflow: ['iflow'],
|
||||
kilo: ['kilo'],
|
||||
qoder: ['qoder'],
|
||||
kiro: ['kiro', 'aws'],
|
||||
ghcp: ['github', 'copilot'],
|
||||
kimi: ['kimi', 'moonshot'],
|
||||
@@ -282,6 +284,7 @@ export function ProviderEditor({
|
||||
isDeletePending={deletePresetMutation.isPending}
|
||||
accounts={accounts}
|
||||
onAddAccount={onAddAccount}
|
||||
onReauthAccount={onReauthAccount}
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
|
||||
@@ -40,6 +40,7 @@ interface ModelConfigTabProps {
|
||||
isDeletePending?: boolean;
|
||||
accounts: OAuthAccount[];
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -82,6 +83,7 @@ export function ModelConfigTab({
|
||||
isDeletePending,
|
||||
accounts,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -176,6 +178,7 @@ export function ModelConfigTab({
|
||||
<AccountsSection
|
||||
accounts={accounts}
|
||||
onAddAccount={onAddAccount}
|
||||
onReauthAccount={onReauthAccount}
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ProviderEditorProps {
|
||||
/** Optional contextual notice shown directly under the editor header */
|
||||
topNotice?: ReactNode;
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -63,6 +64,7 @@ export interface AccountItemProps {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
onReauth?: () => void;
|
||||
onPauseToggle?: (paused: boolean) => void;
|
||||
/** Solo mode: activate this account, pause all others */
|
||||
onSoloMode?: () => void;
|
||||
|
||||
@@ -17,20 +17,30 @@ export interface LeafItem {
|
||||
|
||||
export type DerivedItem = LeafItem | TraceGroup;
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuple key for coalescing standalone leaves. Includes `message` and
|
||||
* `source` so two adjacent logs that share event/module/level but report
|
||||
* different content (or come from a different service) stay distinct
|
||||
* (e.g. `User logged in: alice` and `User logged in: bob`). Excludes
|
||||
* `latencyMs` and `metadata` because those drift per request even on
|
||||
* truly redundant polls — including them would prevent any coalescing.
|
||||
* Tuple key for coalescing standalone leaves. The row/detail UI exposes more
|
||||
* than event/module/level, so every inspectable payload field must participate
|
||||
* in equality; otherwise two adjacent logs with different metadata, context,
|
||||
* error, or latency could collapse into one selectable row. `timestamp` stays
|
||||
* out of the key because collapsed rows surface their time span separately.
|
||||
*
|
||||
* NB: this only applies to *leaves* (entries without `requestId`). Trace
|
||||
* children render uncoalesced so retries and duplicated-stage emissions
|
||||
* stay individually inspectable.
|
||||
*/
|
||||
function coalesceKey(entry: LogsEntry): string {
|
||||
return [
|
||||
return JSON.stringify([
|
||||
entry.event ?? '',
|
||||
entry.message ?? '',
|
||||
entry.stage ?? '',
|
||||
@@ -38,7 +48,13 @@ function coalesceKey(entry: LogsEntry): string {
|
||||
entry.level,
|
||||
entry.requestId ?? '',
|
||||
entry.source ?? '',
|
||||
].join(' ');
|
||||
entry.runId ?? '',
|
||||
String(entry.processId ?? ''),
|
||||
String(entry.latencyMs ?? ''),
|
||||
stableStringify(entry.context),
|
||||
stableStringify(entry.metadata),
|
||||
stableStringify(entry.error),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { LogLevelBadge } from './log-level-badge';
|
||||
import { LogsEmpty } from './logs-empty';
|
||||
import {
|
||||
formatJson,
|
||||
formatLogTimestampIso,
|
||||
getDisplayLatency,
|
||||
getDisplayModule,
|
||||
getDisplayRequestId,
|
||||
@@ -34,7 +35,7 @@ interface OverviewRow {
|
||||
function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] {
|
||||
// Use shared accessors so this panel and the list row never diverge.
|
||||
return [
|
||||
{ label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true },
|
||||
{ label: 'Time', value: formatLogTimestampIso(entry.timestamp), mono: true },
|
||||
{ label: 'Level', value: entry.level },
|
||||
{ label: 'Module', value: getDisplayModule(entry, sourceLabel) },
|
||||
{ label: 'Stage', value: getDisplayStage(entry) },
|
||||
|
||||
@@ -21,6 +21,19 @@ export function formatLogTimestamp(timestamp: string | null | undefined) {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatLogTimestampIso(timestamp: string | null | undefined) {
|
||||
if (!timestamp) {
|
||||
return 'No activity yet';
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function formatRelativeLogTime(timestamp: string | null | undefined) {
|
||||
if (!timestamp) {
|
||||
return 'No activity yet';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
|
||||
import { getProviderDisplayName } from '@/lib/provider-config';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
@@ -26,14 +26,25 @@ export interface AuthMonitorData {
|
||||
/** Hook for computing auth monitor data from CLIProxy auth and stats */
|
||||
export function useAuthMonitorData(): AuthMonitorData {
|
||||
const { data, isLoading, error } = useCliproxyAuth();
|
||||
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
|
||||
const { data: proxyStatus } = useCliproxyStatus();
|
||||
const statsEnabled = proxyStatus?.running === true;
|
||||
const {
|
||||
data: statsData,
|
||||
isLoading: statsLoading,
|
||||
dataUpdatedAt,
|
||||
} = useCliproxyStats(statsEnabled);
|
||||
const activeStatsData = statsEnabled ? statsData : undefined;
|
||||
const activeStatsUpdatedAt = statsEnabled ? dataUpdatedAt : 0;
|
||||
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
|
||||
|
||||
// Live countdown showing time since last data update
|
||||
useEffect(() => {
|
||||
if (!dataUpdatedAt) return;
|
||||
if (!activeStatsUpdatedAt) {
|
||||
setTimeSinceUpdate('');
|
||||
return;
|
||||
}
|
||||
const updateTime = () => {
|
||||
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
|
||||
const diff = Math.floor((Date.now() - activeStatsUpdatedAt) / 1000);
|
||||
if (diff < 60) {
|
||||
setTimeSinceUpdate(`${diff}s ago`);
|
||||
} else {
|
||||
@@ -43,7 +54,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [dataUpdatedAt]);
|
||||
}, [activeStatsUpdatedAt]);
|
||||
|
||||
// Transform auth status data into account rows
|
||||
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
|
||||
@@ -79,7 +90,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
provider: account.provider || status.provider,
|
||||
}));
|
||||
|
||||
buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
|
||||
buildAccountVisualGroups(normalizedAccounts, activeStatsData).forEach((groupedAccount) => {
|
||||
tSuccess += groupedAccount.successCount;
|
||||
tFailure += groupedAccount.failureCount;
|
||||
providerData.success += groupedAccount.successCount;
|
||||
@@ -131,7 +142,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
totalRequests: tSuccess + tFailure,
|
||||
providerStats: providerStatsArr,
|
||||
};
|
||||
}, [data?.authStatus, statsData]);
|
||||
}, [data?.authStatus, activeStatsData]);
|
||||
|
||||
const overallSuccessRate =
|
||||
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
|
||||
@@ -143,7 +154,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
totalRequests,
|
||||
providerStats,
|
||||
overallSuccessRate,
|
||||
isLoading: isLoading || statsLoading,
|
||||
isLoading: isLoading || (statsEnabled && statsLoading),
|
||||
error: error ?? null,
|
||||
timeSinceUpdate,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
usePauseAccount,
|
||||
useResumeAccount,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||
import { Activity, AlertTriangle, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||
|
||||
import { useAuthMonitorData } from './hooks';
|
||||
import { LivePulse } from './components/live-pulse';
|
||||
@@ -86,6 +86,8 @@ export function AuthMonitor() {
|
||||
const displayedSuccessRate = selectedProviderData
|
||||
? getSuccessRate(selectedProviderData.successCount, selectedProviderData.failureCount)
|
||||
: overallSuccessRate;
|
||||
const showHighFailureGuidance =
|
||||
displayedAccountCount >= 5 && displayedFailure >= 20 && displayedSuccessRate < 50;
|
||||
|
||||
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
|
||||
if (
|
||||
@@ -204,6 +206,21 @@ export function AuthMonitor() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showHighFailureGuidance && (
|
||||
<div className="flex items-start gap-3 border-b border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-foreground">High CLIProxy failure rate detected</p>
|
||||
<p className="text-muted-foreground">
|
||||
Select the affected provider, pause failing accounts, then check{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono">ccs cliproxy routing</code>.
|
||||
For large Codex pools, prefer a smaller healthy active set and attach CLIProxy logs if
|
||||
failures continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow Visualization */}
|
||||
<div className="relative overflow-hidden">
|
||||
{selectedProviderData ? (
|
||||
|
||||
@@ -25,6 +25,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
|
||||
'iflow',
|
||||
'kilo',
|
||||
'kiro',
|
||||
'qoder',
|
||||
];
|
||||
|
||||
export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({
|
||||
|
||||
@@ -378,7 +378,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
if (isGeminiQuotaResult(quota)) {
|
||||
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
||||
const hasEntitlementTier =
|
||||
!!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown';
|
||||
!!quota.entitlement?.rawTierLabel ||
|
||||
(!!quota.entitlement && quota.entitlement.normalizedTier !== 'unknown');
|
||||
const distinctTokenTypes = Array.from(
|
||||
new Set(
|
||||
quota.buckets
|
||||
|
||||
@@ -25,6 +25,7 @@ interface AuthFlowState {
|
||||
}
|
||||
|
||||
interface StartAuthOptions {
|
||||
accountId?: string;
|
||||
nickname?: string;
|
||||
kiroMethod?: string;
|
||||
kiroIDCStartUrl?: string;
|
||||
@@ -286,6 +287,7 @@ export function useCliproxyAuthFlow() {
|
||||
const deviceCodeFlow = flowType === 'device_code';
|
||||
const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url');
|
||||
const payload = {
|
||||
accountId: options?.accountId,
|
||||
nickname: options?.nickname,
|
||||
kiroMethod: options?.kiroMethod,
|
||||
kiroIDCStartUrl: options?.kiroIDCStartUrl,
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
function normalizeVersionParts(version: string): number[] {
|
||||
return version.replace(/-\d+$/, '').split('.').map(Number);
|
||||
interface ParsedCliproxyVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
forkRelease: number;
|
||||
}
|
||||
|
||||
function parseCliproxyVersion(version: string): ParsedCliproxyVersion {
|
||||
const normalized = version.trim().replace(/^v/, '');
|
||||
const [coreVersion, forkReleaseValue = '0'] = normalized.split('-', 2);
|
||||
const [major = 0, minor = 0, patch = 0] = coreVersion
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
const forkRelease = /^\d+$/.test(forkReleaseValue)
|
||||
? Number.parseInt(forkReleaseValue, 10) || 0
|
||||
: 0;
|
||||
|
||||
return { major, minor, patch, forkRelease };
|
||||
}
|
||||
|
||||
function compareVersionPart(a: number, b: number): number {
|
||||
if (a > b) return 1;
|
||||
if (a < b) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function compareCliproxyVersions(a: string, b: string): number {
|
||||
const aParts = normalizeVersionParts(a);
|
||||
const bParts = normalizeVersionParts(b);
|
||||
const left = parseCliproxyVersion(a);
|
||||
const right = parseCliproxyVersion(b);
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const aPart = aParts[index] || 0;
|
||||
const bPart = bParts[index] || 0;
|
||||
|
||||
if (aPart > bPart) return 1;
|
||||
if (aPart < bPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return (
|
||||
compareVersionPart(left.major, right.major) ||
|
||||
compareVersionPart(left.minor, right.minor) ||
|
||||
compareVersionPart(left.patch, right.patch) ||
|
||||
compareVersionPart(left.forkRelease, right.forkRelease)
|
||||
);
|
||||
}
|
||||
|
||||
export function isCliproxyVersionExperimental(version: string, maxStableVersion: string): boolean {
|
||||
|
||||
@@ -454,6 +454,128 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
qoder: {
|
||||
provider: 'qoder',
|
||||
displayName: 'Qoder',
|
||||
defaultModel: 'qoder/auto',
|
||||
models: [
|
||||
{
|
||||
id: 'qoder/auto',
|
||||
name: 'Qoder Auto',
|
||||
description: 'Auto selects the best Qoder model for your prompt',
|
||||
presetMapping: {
|
||||
default: 'qoder/auto',
|
||||
opus: 'qoder/auto',
|
||||
sonnet: 'qoder/auto',
|
||||
haiku: 'qoder/auto',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/ultimate',
|
||||
name: 'Qoder Ultimate',
|
||||
description: 'Highest quality Qoder tier',
|
||||
presetMapping: {
|
||||
default: 'qoder/ultimate',
|
||||
opus: 'qoder/ultimate',
|
||||
sonnet: 'qoder/ultimate',
|
||||
haiku: 'qoder/ultimate',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/performance',
|
||||
name: 'Qoder Performance',
|
||||
description: 'Balanced quality and speed',
|
||||
presetMapping: {
|
||||
default: 'qoder/performance',
|
||||
opus: 'qoder/performance',
|
||||
sonnet: 'qoder/performance',
|
||||
haiku: 'qoder/performance',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/efficient',
|
||||
name: 'Qoder Efficient',
|
||||
description: 'Cost-efficient Qoder tier',
|
||||
presetMapping: {
|
||||
default: 'qoder/efficient',
|
||||
opus: 'qoder/efficient',
|
||||
sonnet: 'qoder/efficient',
|
||||
haiku: 'qoder/efficient',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/lite',
|
||||
name: 'Qoder Lite',
|
||||
description: 'Fastest and most affordable Qoder tier',
|
||||
presetMapping: {
|
||||
default: 'qoder/lite',
|
||||
opus: 'qoder/lite',
|
||||
sonnet: 'qoder/lite',
|
||||
haiku: 'qoder/lite',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/qmodel',
|
||||
name: 'Qwen 3.6 Plus (via Qoder)',
|
||||
description: 'Qwen 3.6 Plus frontier model',
|
||||
presetMapping: {
|
||||
default: 'qoder/qmodel',
|
||||
opus: 'qoder/qmodel',
|
||||
sonnet: 'qoder/qmodel',
|
||||
haiku: 'qoder/qmodel',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/dmodel',
|
||||
name: 'DeepSeek V4 Pro (via Qoder)',
|
||||
description: 'DeepSeek V4 Pro frontier model',
|
||||
presetMapping: {
|
||||
default: 'qoder/dmodel',
|
||||
opus: 'qoder/dmodel',
|
||||
sonnet: 'qoder/dmodel',
|
||||
haiku: 'qoder/dfmodel',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/dfmodel',
|
||||
name: 'DeepSeek V4 Flash (via Qoder)',
|
||||
description: 'DeepSeek V4 Flash frontier model',
|
||||
},
|
||||
{
|
||||
id: 'qoder/gm51model',
|
||||
name: 'GLM 5.1 (via Qoder)',
|
||||
description: 'GLM 5.1 frontier model',
|
||||
presetMapping: {
|
||||
default: 'qoder/gm51model',
|
||||
opus: 'qoder/gm51model',
|
||||
sonnet: 'qoder/gm51model',
|
||||
haiku: 'qoder/gm51model',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/kmodel',
|
||||
name: 'Kimi K2.6 (via Qoder)',
|
||||
description: 'Kimi K2.6 frontier model',
|
||||
presetMapping: {
|
||||
default: 'qoder/kmodel',
|
||||
opus: 'qoder/kmodel',
|
||||
sonnet: 'qoder/kmodel',
|
||||
haiku: 'qoder/kmodel',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qoder/mmodel',
|
||||
name: 'MiniMax M2.7 (via Qoder)',
|
||||
description: 'MiniMax M2.7 frontier model',
|
||||
presetMapping: {
|
||||
default: 'qoder/mmodel',
|
||||
opus: 'qoder/mmodel',
|
||||
sonnet: 'qoder/mmodel',
|
||||
haiku: 'qoder/mmodel',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
kimi: {
|
||||
provider: 'kimi',
|
||||
displayName: 'Kimi (Moonshot)',
|
||||
|
||||
@@ -105,6 +105,7 @@ export const PROVIDER_ASSETS: Partial<Record<ProviderVisualId, string>> = {
|
||||
gitlab: '/assets/providers/gitlab.svg',
|
||||
codebuddy: '/assets/providers/codebuddy.png',
|
||||
kilo: '/assets/providers/kilo.png',
|
||||
qoder: '/assets/providers/qoder.svg',
|
||||
ghcp: '/assets/providers/copilot.svg',
|
||||
claude: '/assets/providers/claude.svg',
|
||||
kimi: '/assets/providers/kimi.svg',
|
||||
@@ -135,6 +136,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record<ProviderVisualId, ProviderFallbac
|
||||
gitlab: { textClass: 'text-orange-600', letter: 'G' },
|
||||
codebuddy: { textClass: 'text-blue-600', letter: 'B' },
|
||||
kilo: { textClass: 'text-rose-600', letter: 'K' },
|
||||
qoder: { textClass: 'text-amber-600', letter: 'Q' },
|
||||
ghcp: { textClass: 'text-green-600', letter: 'C' },
|
||||
kimi: { textClass: 'text-orange-500', letter: 'K' },
|
||||
openai: { textClass: 'text-slate-900', letter: 'O' },
|
||||
@@ -272,6 +274,7 @@ export const PROVIDER_COLORS: Record<string, string> = {
|
||||
gitlab: '#FC6D26',
|
||||
codebuddy: '#2563EB',
|
||||
kilo: '#E11D48',
|
||||
qoder: '#D97706',
|
||||
ghcp: '#43aa8b',
|
||||
claude: '#D97757',
|
||||
kimi: '#FF6B35',
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Info, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { AnalyticsProfileOption } from '../hooks';
|
||||
|
||||
interface AnalyticsHeaderProps {
|
||||
@@ -53,21 +54,44 @@ export function AnalyticsHeader({
|
||||
<p className="text-sm text-muted-foreground">{t('analytics.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<Select value={selectedProfile} onValueChange={onProfileChange}>
|
||||
<SelectTrigger className="h-8 w-[190px]" aria-label="Analytics profile">
|
||||
<SelectValue placeholder="All profiles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profileOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} disabled={!option.supported}>
|
||||
<span className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{option.description}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="inline-flex items-center gap-1 shrink-0">
|
||||
<Select value={selectedProfile} onValueChange={onProfileChange}>
|
||||
<SelectTrigger className="h-8 w-[170px]" aria-label="Analytics profile">
|
||||
<SelectValue placeholder="All profiles">
|
||||
{profileOptions.find((o) => o.value === selectedProfile)?.label ?? 'All profiles'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profileOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} disabled={!option.supported}>
|
||||
<span className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{option.description}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedProfile !== 'all' && (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Selected-profile analytics scope"
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Info className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs text-xs">
|
||||
Selected-profile analytics include only default/account data with stable profile
|
||||
attribution. CLIProxy and native runtime snapshots remain in All profiles.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={viewMode === 'hourly' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
@@ -92,8 +116,11 @@ export function AnalyticsHeader({
|
||||
/>
|
||||
|
||||
{lastUpdatedText && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t('analytics.updated', { value: lastUpdatedText })}
|
||||
<span
|
||||
className="text-xs text-muted-foreground whitespace-nowrap font-mono"
|
||||
title={t('analytics.updated', { value: lastUpdatedText })}
|
||||
>
|
||||
{lastUpdatedText}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
@@ -106,12 +133,6 @@ export function AnalyticsHeader({
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
{selectedProfile !== 'all' && (
|
||||
<p className="text-xs text-muted-foreground xl:text-right">
|
||||
Selected-profile analytics include only default/account data with stable profile
|
||||
attribution. CLIProxy and native runtime snapshots remain in All profiles.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,18 @@
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { subDays, formatDistanceToNow } from 'date-fns';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
function formatCompactRelativeTime(date: Date): string {
|
||||
const seconds = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
import {
|
||||
useUsageSummary,
|
||||
useUsageTrends,
|
||||
@@ -151,10 +162,10 @@ export function useAnalyticsPage() {
|
||||
persistSelectedProfile(profile);
|
||||
}, []);
|
||||
|
||||
// Format "Last updated" text
|
||||
// Compact "Last updated" text (e.g. "1m ago", "2h ago")
|
||||
const lastUpdatedText = useMemo(() => {
|
||||
if (!status?.lastFetch) return null;
|
||||
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
|
||||
return formatCompactRelativeTime(new Date(status.lastFetch));
|
||||
}, [status?.lastFetch]);
|
||||
|
||||
// Handle model click for popover
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
useBulkResumeAccounts,
|
||||
useDeleteVariant,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||
import type { AuthStatus, OAuthAccount, Variant } from '@/lib/api-client';
|
||||
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
@@ -256,6 +256,7 @@ export function CliproxyPage() {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
isFirstAccount: boolean;
|
||||
account?: OAuthAccount;
|
||||
} | null>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
@@ -404,6 +405,23 @@ export function CliproxyPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleReauthAccount = (
|
||||
provider: string,
|
||||
displayName: string,
|
||||
accounts: OAuthAccount[] | undefined,
|
||||
accountId: string
|
||||
) => {
|
||||
const account = accounts?.find((candidate) => candidate.id === accountId);
|
||||
if (!account) return;
|
||||
|
||||
setAddAccountProvider({
|
||||
provider,
|
||||
displayName,
|
||||
isFirstAccount: false,
|
||||
account,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
@@ -567,6 +585,14 @@ export function CliproxyPage() {
|
||||
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onReauthAccount={(account) =>
|
||||
handleReauthAccount(
|
||||
selectedVariantData.provider,
|
||||
parentAuthForVariant.displayName,
|
||||
parentAuthForVariant.accounts,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
@@ -617,6 +643,14 @@ export function CliproxyPage() {
|
||||
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onReauthAccount={(account) =>
|
||||
handleReauthAccount(
|
||||
selectedStatus.provider,
|
||||
selectedStatus.displayName,
|
||||
selectedStatus.accounts,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
@@ -663,6 +697,7 @@ export function CliproxyPage() {
|
||||
: undefined
|
||||
}
|
||||
isFirstAccount={addAccountProvider?.isFirstAccount || false}
|
||||
account={addAccountProvider?.account}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('ControlPanelEmbed', () => {
|
||||
const iframe = await screen.findByTitle('CLIProxy Management Panel');
|
||||
|
||||
expect(iframe).toHaveAttribute('src', '/api/cliproxy-local/management.html');
|
||||
fireEvent.load(iframe);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.localStorage.removeItem).toHaveBeenCalledWith('cli-proxy-auth');
|
||||
@@ -107,6 +108,7 @@ describe('ControlPanelEmbed', () => {
|
||||
);
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith('managementKey', 'custom-secret');
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith('isLoggedIn', 'true');
|
||||
expect(window.localStorage.removeItem).toHaveBeenCalledWith('managementKey');
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -86,6 +86,47 @@ describe('useCliproxyAuthFlow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the selected account id when reauth starts from the dashboard', async () => {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/codex/start-url')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
success: true,
|
||||
authUrl: 'https://auth.example.com/codex?state=codex-reauth',
|
||||
state: 'codex-reauth',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/status?state=codex-reauth')) {
|
||||
return Promise.resolve(createJsonResponse({ status: 'wait' }));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startAuth('codex', {
|
||||
accountId: 'existing@example.com',
|
||||
startEndpoint: 'start-url',
|
||||
});
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/cliproxy/auth/codex/start-url',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"accountId":"existing@example.com"'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces manual OAuth start guidance from the dashboard API', async () => {
|
||||
const guidance = [
|
||||
'Start local CLIProxy first: ccs cliproxy start',
|
||||
|
||||
@@ -141,6 +141,65 @@ describe('deriveTraceGroups', () => {
|
||||
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps adjacent leaves with different detail payloads as separate rows', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{
|
||||
id: '1',
|
||||
timestamp: 't1',
|
||||
event: 'poll',
|
||||
message: 'same',
|
||||
latencyMs: 25,
|
||||
metadata: { attempt: 1, nested: { status: 'warm' } },
|
||||
context: { account: 'alpha' },
|
||||
error: { code: 'E_ONE', message: 'first' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: 't2',
|
||||
event: 'poll',
|
||||
message: 'same',
|
||||
latencyMs: 50,
|
||||
metadata: { attempt: 2, nested: { status: 'cold' } },
|
||||
context: { account: 'beta' },
|
||||
error: { code: 'E_TWO', message: 'second' },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
|
||||
expect(result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace'))).toEqual(['2', '1']);
|
||||
});
|
||||
|
||||
it('coalesces leaves with semantically identical structured payloads', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{
|
||||
id: '1',
|
||||
timestamp: 't1',
|
||||
event: 'poll',
|
||||
message: 'tick',
|
||||
latencyMs: 25,
|
||||
metadata: { b: 2, a: 1 },
|
||||
context: { nested: { b: false, a: true } },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: 't2',
|
||||
event: 'poll',
|
||||
message: 'tick',
|
||||
latencyMs: 25,
|
||||
metadata: { a: 1, b: 2 },
|
||||
context: { nested: { a: true, b: false } },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as LeafItem).repeatCount).toBe(2);
|
||||
});
|
||||
|
||||
it('display-sorts items reverse-chronologically', () => {
|
||||
// Use distinct events so leaves don't coalesce — testing display sort,
|
||||
// not coalesce.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from '@tests/setup/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
|
||||
function buildEntry(overrides: Partial<LogsEntry> = {}): LogsEntry {
|
||||
return {
|
||||
id: 'entry-1',
|
||||
timestamp: '2026-04-07T11:00:00.000Z',
|
||||
level: 'info',
|
||||
source: 'dashboard',
|
||||
event: 'logs.bootstrap',
|
||||
message: 'Dashboard log entry',
|
||||
processId: 25582,
|
||||
runId: 'run-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('LogsDetailPanel', () => {
|
||||
it('falls back to the raw timestamp when the selected log entry has an invalid timestamp', () => {
|
||||
render(<LogsDetailPanel entry={buildEntry({ timestamp: 'not-a-date' })} />);
|
||||
|
||||
expect(screen.getByText('not-a-date')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('not-a-date')).toHaveTextContent('not-a-date');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthMonitorData } from '@/components/monitoring/auth-monitor/hooks';
|
||||
|
||||
const { useCliproxyAuthMock, useCliproxyStatsMock, useCliproxyStatusMock } = vi.hoisted(() => ({
|
||||
useCliproxyAuthMock: vi.fn(),
|
||||
useCliproxyStatsMock: vi.fn(),
|
||||
useCliproxyStatusMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cliproxy', () => ({
|
||||
useCliproxyAuth: useCliproxyAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cliproxy-stats', () => ({
|
||||
useCliproxyStats: useCliproxyStatsMock,
|
||||
useCliproxyStatus: useCliproxyStatusMock,
|
||||
}));
|
||||
|
||||
const authStatus = [
|
||||
{
|
||||
provider: 'codex',
|
||||
displayName: 'OpenAI Codex',
|
||||
accounts: [
|
||||
{
|
||||
id: 'codex-account',
|
||||
email: 'codex@example.com',
|
||||
tokenFile: '/tmp/codex.json',
|
||||
provider: 'codex',
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('useAuthMonitorData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCliproxyAuthMock.mockReturnValue({
|
||||
data: { authStatus },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
useCliproxyStatsMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
dataUpdatedAt: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps account data visible without polling stats when CLIProxy is unavailable', () => {
|
||||
useCliproxyStatusMock.mockReturnValue({
|
||||
data: { running: false },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuthMonitorData());
|
||||
|
||||
expect(useCliproxyStatsMock).toHaveBeenCalledWith(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.accounts).toHaveLength(1);
|
||||
expect(result.current.totalRequests).toBe(0);
|
||||
expect(result.current.providerStats[0]).toMatchObject({
|
||||
provider: 'codex',
|
||||
accountCount: 1,
|
||||
totalRequests: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('enables live stats only after CLIProxy is running', () => {
|
||||
useCliproxyStatusMock.mockReturnValue({
|
||||
data: { running: true },
|
||||
isLoading: false,
|
||||
});
|
||||
useCliproxyStatsMock.mockReturnValue({
|
||||
data: {
|
||||
accountStats: {
|
||||
'codex:codex@example.com': {
|
||||
source: 'codex@example.com',
|
||||
successCount: 8,
|
||||
failureCount: 2,
|
||||
totalTokens: 100,
|
||||
provider: 'codex',
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
dataUpdatedAt: Date.now(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuthMonitorData());
|
||||
|
||||
expect(useCliproxyStatsMock).toHaveBeenCalledWith(true);
|
||||
expect(result.current.totalSuccess).toBe(8);
|
||||
expect(result.current.totalFailure).toBe(2);
|
||||
expect(result.current.totalRequests).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -191,4 +191,41 @@ describe('AuthMonitor', () => {
|
||||
expect(within(getSummaryCard('Failed')).getByText('3')).toBeInTheDocument();
|
||||
expect(within(getSummaryCard('Success Rate')).getByText('80%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows CLIProxy guidance when a large account pool has a high failure rate', () => {
|
||||
useAuthMonitorDataMock.mockReturnValue({
|
||||
...authMonitorData,
|
||||
accounts: Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `codex-${index}`,
|
||||
email: `codex-${index}@example.com`,
|
||||
tokenFile: `/tmp/codex-${index}.json`,
|
||||
provider: 'codex',
|
||||
displayName: 'OpenAI Codex',
|
||||
isDefault: index === 0,
|
||||
successCount: index === 0 ? 1 : 0,
|
||||
failureCount: 5,
|
||||
color: '#10a37f',
|
||||
})),
|
||||
totalSuccess: 1,
|
||||
totalFailure: 25,
|
||||
totalRequests: 26,
|
||||
providerStats: [
|
||||
{
|
||||
provider: 'codex',
|
||||
displayName: 'OpenAI Codex',
|
||||
totalRequests: 26,
|
||||
successCount: 1,
|
||||
failureCount: 25,
|
||||
accountCount: 5,
|
||||
accounts: [],
|
||||
},
|
||||
],
|
||||
overallSuccessRate: 4,
|
||||
});
|
||||
|
||||
render(<AuthMonitor />);
|
||||
|
||||
expect(screen.getByText('High CLIProxy failure rate detected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/ccs cliproxy routing/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,20 @@ describe('QuotaTooltipContent', () => {
|
||||
expect(screen.getByText(expectedReset)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Gemini tier label when entitlement evidence is absent', () => {
|
||||
const quota = createGeminiQuotaResult({
|
||||
entitlement: undefined,
|
||||
tierLabel: 'Legacy Pro',
|
||||
tierId: null,
|
||||
creditBalance: null,
|
||||
});
|
||||
|
||||
render(<QuotaTooltipContent quota={quota} resetTime={null} />);
|
||||
|
||||
expect(screen.getByText('Tier')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legacy Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the shared reset indicator when Gemini buckets omit reset timestamps', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-29T00:00:00Z'));
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('provider presentation metadata', () => {
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
'qoder',
|
||||
]);
|
||||
expect(getProviderSection('gitlab')?.id).toBe('plus-extra');
|
||||
expect(getProviderSection('gemini')?.id).toBe('core');
|
||||
@@ -128,6 +129,7 @@ describe('provider presentation metadata', () => {
|
||||
'/assets/providers/codebuddy.png',
|
||||
],
|
||||
['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.png'],
|
||||
['qoder', 'Qoder', 'Qoder AI coding assistant', '/assets/providers/qoder.svg'],
|
||||
])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => {
|
||||
expect(getProviderDisplayName(provider)).toBe(name);
|
||||
expect(getProviderDescription(provider)).toBe(description);
|
||||
|
||||
Reference in new issue
Block a user