refactor(ui): split error-logs-monitor into monitoring/error-logs/ directory

- extract 617-line component into 6 focused modules

- move auth-monitor, proxy-status-widget to monitoring/

- add barrel export in monitoring/index.ts
This commit is contained in:
kaitranntt
2025-12-19 19:40:43 -05:00
parent 1b1015cf50
commit 946030c836
10 changed files with 1336 additions and 613 deletions
+14 -613
View File
@@ -1,617 +1,18 @@
/**
* Error Logs Monitor Component
*
* Displays CLIProxyAPI error logs with master-detail split view.
* ETL: Parses raw logs into structured data for rich display.
* - Left panel: Log list with status code, provider, endpoint, relative time
* - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw)
* Error Logs Monitor - Re-export from modular structure
* @deprecated Import from '@/components/monitoring/error-logs' directly
*/
import { useState, useMemo, useRef, useEffect } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ProviderIcon } from '@/components/provider-icon';
import { CopyButton } from '@/components/ui/copy-button';
import {
AlertTriangle,
FileWarning,
Clock,
FileText,
Terminal,
Info,
Code,
ArrowUpRight,
ArrowDownLeft,
GripVertical,
GripHorizontal,
} from 'lucide-react';
import {
parseErrorLog,
parseFilename,
formatRelativeTime,
formatBytes,
getStatusColor,
getErrorTypeLabel,
type ParsedErrorLog,
} from '@/lib/error-log-parser';
export {
ErrorLogsMonitor,
ErrorLogItem,
LogContentPanel,
TabButton,
StatusBadge,
OverviewTab,
HeadersTab,
BodyTab,
RawTab,
} from './monitoring/error-logs';
type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw';
/** Tab button component */
function TabButton({
active,
onClick,
children,
icon: Icon,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<button
onClick={onClick}
className={cn(
'px-2.5 py-1.5 text-xs font-medium rounded transition-colors flex items-center gap-1.5',
active
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
{Icon && <Icon className="w-3.5 h-3.5" />}
{children}
</button>
);
}
/** Status badge component */
function StatusBadge({ code }: { code: number }) {
const colorClass = getStatusColor(code);
return (
<span
className={cn(
'inline-flex items-center justify-center min-w-[36px] px-2 py-0.5 rounded text-xs font-bold',
'bg-current/10 border border-current/20',
colorClass
)}
>
{code}
</span>
);
}
/** Overview tab content */
function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
return (
<div className="p-4 space-y-4">
{/* Status row */}
<div className="flex items-center gap-3">
<StatusBadge code={parsed.statusCode} />
<span className="text-sm font-medium">{parsed.statusText}</span>
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
{getErrorTypeLabel(parsed.errorType)}
</span>
</div>
{/* Key metrics grid */}
<div className="grid grid-cols-4 gap-3 text-xs">
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Method</div>
<div className="font-medium">{parsed.method || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Provider</div>
<div className="font-medium">{parsed.provider || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Version</div>
<div className="font-medium">{parsed.version || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Endpoint</div>
<div className="font-medium truncate" title={parsed.endpoint}>
{parsed.endpoint || 'N/A'}
</div>
</div>
</div>
{/* URL */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">URL</div>
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
{parsed.url || 'N/A'}
</div>
</div>
{/* Timestamp */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">Timestamp</div>
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
</div>
{/* Suggestion based on error type */}
{parsed.errorType !== 'unknown' && (
<div className="flex items-start gap-3 p-3 rounded bg-blue-500/10 border border-blue-500/20 text-xs">
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
<div className="text-blue-500/90 leading-relaxed">
{parsed.errorType === 'rate_limit' &&
'Rate limited. Consider using multiple accounts or reducing request frequency.'}
{parsed.errorType === 'auth' &&
'Authentication failed. Check credentials or re-authenticate with the provider.'}
{parsed.errorType === 'not_found' &&
'Endpoint not found. This endpoint may not exist on this provider.'}
{parsed.errorType === 'server' &&
'Server error from upstream. Retry or check provider status.'}
{parsed.errorType === 'timeout' &&
'Request timed out. Check network or increase timeout settings.'}
</div>
</div>
)}
</div>
);
}
/** Headers tab content */
function HeadersTab({ headers }: { headers: Record<string, string> }) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return <div className="p-4 text-xs text-muted-foreground">No headers available</div>;
}
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-1">
{entries.map(([key, value]) => (
<div
key={key}
className="flex gap-3 text-xs font-mono py-1.5 border-b border-border/30 last:border-0"
>
<span className="text-muted-foreground shrink-0 min-w-[140px]">{key}:</span>
<span className="break-all">{value}</span>
</div>
))}
</div>
</ScrollArea>
);
}
/** JSON/Body tab content */
function BodyTab({ content, label }: { content: string; label: string }) {
if (!content || content.trim() === '') {
return <div className="p-4 text-xs text-muted-foreground">No {label.toLowerCase()} body</div>;
}
// Try to format as JSON
let formatted = content;
let isJson = false;
try {
const parsed = JSON.parse(content);
formatted = JSON.stringify(parsed, null, 2);
isJson = true;
} catch {
// Not JSON, use as-is
}
return (
<ScrollArea className="h-full">
<pre
className={cn(
'p-4 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed',
isJson
? 'text-emerald-700 dark:text-green-400'
: 'text-zinc-700 dark:text-muted-foreground'
)}
>
{formatted}
</pre>
</ScrollArea>
);
}
/** Raw tab content */
function RawTab({ content }: { content: string }) {
return (
<ScrollArea className="h-full">
<pre className="p-4 text-xs font-mono text-zinc-700 dark:text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
{content}
</pre>
</ScrollArea>
);
}
/** Log content panel with tabs */
function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) {
const [activeTab, setActiveTab] = useState<TabType>('overview');
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
// Parse log content
const parsed = useMemo(() => {
if (!content) return null;
return parseErrorLog(content);
}, [content]);
// No log selected
if (!name) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-3">
<Terminal className="w-10 h-10 mx-auto opacity-40" />
<p className="text-sm">Select a log to view details</p>
</div>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className="flex-1 p-6 space-y-3">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-5/6" />
<Skeleton className="h-5 w-2/3" />
</div>
);
}
// Error or no content
if (error || !content || !parsed) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="text-sm">Failed to load log content</p>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-w-0 h-full">
{/* Header with status */}
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<StatusBadge code={parsed.statusCode} />
<span className="text-xs font-semibold truncate text-foreground">
{parsed.provider}/{parsed.endpoint || 'unknown'}
</span>
{/* Copy Absolute Path Button */}
{name && (
<CopyButton
value={absolutePath || name}
label="Copy absolute path"
size="icon-sm"
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
/>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Copy Raw Content Button */}
{content && (
<CopyButton
value={content}
label="Copy raw log content"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
/>
)}
<span className="text-[10px] text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded border border-border/50">
{parsed.method}
</span>
</div>
</div>
{/* Tabs */}
<div className="px-3 py-2 border-b border-border flex items-center gap-1 bg-muted/10 shrink-0 overflow-x-auto">
<TabButton
active={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
icon={Info}
>
Overview
</TabButton>
<TabButton
active={activeTab === 'headers'}
onClick={() => setActiveTab('headers')}
icon={Code}
>
Headers
</TabButton>
<TabButton
active={activeTab === 'request'}
onClick={() => setActiveTab('request')}
icon={ArrowUpRight}
>
Request
</TabButton>
<TabButton
active={activeTab === 'response'}
onClick={() => setActiveTab('response')}
icon={ArrowDownLeft}
>
Response
</TabButton>
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
Raw
</TabButton>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden bg-card/30">
{activeTab === 'overview' && <OverviewTab parsed={parsed} />}
{activeTab === 'headers' && <HeadersTab headers={parsed.requestHeaders} />}
{activeTab === 'request' && <BodyTab content={parsed.requestBody} label="Request" />}
{activeTab === 'response' && <BodyTab content={parsed.responseBody} label="Response" />}
{activeTab === 'raw' && <RawTab content={content} />}
</div>
</div>
);
}
/** Error log item in the list */
interface ErrorLogItemProps {
name: string;
size: number;
modified: number;
isSelected: boolean;
onClick: () => void;
}
function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
const parsed = useMemo(() => parseFilename(name), [name]);
return (
<button
onClick={onClick}
className={cn(
'w-full px-3 py-2.5 flex items-start gap-3 text-left transition-colors',
'hover:bg-muted/40 border-l-[3px]',
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
)}
>
{/* Provider Icon */}
<ProviderIcon
provider={parsed.provider}
size={24}
withBackground={true}
className="shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0 space-y-1">
{/* Provider / Endpoint */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-foreground truncate">
{parsed.provider}
</span>
<span
className={cn(
'text-[9px] px-1 rounded border',
isSelected
? 'bg-amber-500/10 text-amber-600 border-amber-500/20'
: 'bg-muted border-border text-muted-foreground'
)}
>
LOG
</span>
</div>
<span
className="text-[11px] text-muted-foreground truncate font-medium"
title={parsed.endpoint}
>
{parsed.endpoint}
</span>
</div>
{/* Meta row: time + size */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/80 mt-1">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatRelativeTime(modified)}
</span>
<span className="flex items-center gap-1">
<FileText className="w-3 h-3" />
{formatBytes(size)}
</span>
</div>
</div>
</button>
);
}
export function ErrorLogsMonitor() {
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
// Vertical resize state
const [height, setHeight] = useState(500);
const [isResizing, setIsResizing] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Auto-scroll handler
const stopAutoScroll = () => {
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
scrollIntervalRef.current = null;
}
};
// Resize handlers
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const containerTopDoc = rect.top + window.scrollY;
const newHeight = e.pageY - containerTopDoc;
// Constrain height (min 300, no max)
setHeight(Math.max(300, newHeight));
// Auto-scroll logic
const viewportHeight = window.innerHeight;
const distFromBottom = viewportHeight - e.clientY;
const scrollSpeed = 15;
stopAutoScroll();
if (distFromBottom < 50) {
scrollIntervalRef.current = setInterval(() => {
window.scrollBy(0, scrollSpeed);
}, 16);
} else if (e.clientY < 50) {
scrollIntervalRef.current = setInterval(() => {
window.scrollBy(0, -scrollSpeed);
}, 16);
}
};
const handleMouseUp = () => {
setIsResizing(false);
stopAutoScroll();
document.body.style.cursor = 'default';
document.body.style.userSelect = 'auto';
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'default';
document.body.style.userSelect = 'auto';
stopAutoScroll();
};
}, [isResizing]);
const startResizing = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
};
// Compute default selection (first log name or null)
const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]);
// Use controlled selection that defaults to first log
const [selectedLog, setSelectedLog] = useState<string | null>(null);
// Effective selection: use user selection if available, otherwise default
const effectiveSelection = selectedLog ?? defaultLogName;
// Get absolute path for the selected log
const selectedAbsolutePath = useMemo(() => {
if (!effectiveSelection || !logs) return undefined;
const log = logs.find((l) => l.name === effectiveSelection);
return log?.absolutePath;
}, [effectiveSelection, logs]);
// Guards
if (isStatusLoading) return null;
if (!status?.running) return null;
if (isLoading) {
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-sm bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm h-[500px]">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="p-4 space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
</div>
);
}
if (!logs || logs.length === 0) return null;
const errorCount = logs.length;
return (
<div
ref={containerRef}
className="rounded-xl border border-border overflow-hidden font-mono text-sm text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm flex flex-col shadow-sm transition-[height] duration-0 ease-linear relative group/container"
style={{ height }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
<div className="flex items-center gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-sm font-semibold tracking-tight">Error Logs</span>
<span className="text-xs text-muted-foreground ml-1">
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileWarning className="w-3.5 h-3.5" />
<span>CLIProxy Diagnostics</span>
</div>
</div>
{/* Resizable Panel Layout */}
<div className="flex-1 min-h-0">
<PanelGroup direction="horizontal">
{/* Left Panel: Log List */}
<Panel defaultSize={30} minSize={20} maxSize={50} className="flex flex-col min-w-0">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{logs.slice(0, 50).map((log) => (
<ErrorLogItem
key={log.name}
name={log.name}
size={log.size}
modified={log.modified}
isSelected={effectiveSelection === log.name}
onClick={() => setSelectedLog(log.name)}
/>
))}
</div>
{logs.length > 50 && (
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
Showing 50 of {logs.length} logs
</div>
)}
</ScrollArea>
</Panel>
{/* Resize Handle */}
<PanelResizeHandle className="w-[1px] bg-border hover:bg-primary/50 transition-colors flex items-center justify-center group relative z-10 w-2 -ml-1 flex items-center justify-center outline-none">
<div className="w-[1px] h-full bg-border group-hover:bg-primary/50 transition-colors" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 rounded-sm flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-muted border border-border">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
</PanelResizeHandle>
{/* Right Panel: Log Content */}
<Panel className="flex flex-col min-w-0 bg-background/50">
<LogContentPanel name={effectiveSelection} absolutePath={selectedAbsolutePath} />
</Panel>
</PanelGroup>
</div>
{/* Use standard footer if error, otherwise show resize handle */}
{error ? (
<div className="px-4 py-2 border-t border-border text-xs text-destructive bg-destructive/5 shrink-0">
{error.message}
</div>
) : (
<div
className="h-2 bg-border/10 border-t border-border/30 hover:bg-primary/10 transition-colors cursor-row-resize flex items-center justify-center group/handle shrink-0"
onMouseDown={startResizing}
>
<GripHorizontal className="w-8 h-3 text-border group-hover:text-primary/50 transition-colors" />
</div>
)}
</div>
);
}
export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './monitoring/error-logs';
@@ -0,0 +1,465 @@
/**
* Auth Monitor Component with Account Flow Visualization
* Shows request flow from accounts to providers using custom SVG bezier curves
* Uses glass panel aesthetic with hover interactions and glow effects
*/
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats';
import { cn, STATUS_COLORS } from '@/lib/utils';
import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config';
import { Skeleton } from '@/components/ui/skeleton';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { AccountFlowViz } from '@/components/account-flow-viz';
import { usePrivacy } from '@/contexts/privacy-context';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react';
interface AccountRow {
id: string;
email: string;
provider: string;
displayName: string;
isDefault: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
color: string;
}
interface ProviderStats {
provider: string;
displayName: string;
totalRequests: number;
successCount: number;
failureCount: number;
accountCount: number;
accounts: AccountRow[];
}
function getSuccessRate(success: number, failure: number): number {
const total = success + failure;
if (total === 0) return 100;
return Math.round((success / total) * 100);
}
/** Strip common email domains for cleaner display */
function cleanEmail(email: string): string {
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
}
// Vibrant colors for account segments - darker for light theme contrast
const ACCOUNT_COLORS = [
'#1e6091', // Deep Cerulean (was #277da1)
'#2d8a6e', // Deep Seaweed (was #43aa8b)
'#d4a012', // Dark Tuscan (was #f9c74f)
'#c92a2d', // Deep Strawberry (was #f94144)
'#c45a1a', // Deep Pumpkin (was #f3722c)
'#6b9c4d', // Dark Willow (was #90be6d)
'#3d5a73', // Deep Blue Slate (was #577590)
'#cc7614', // Dark Carrot (was #f8961e)
'#3a7371', // Deep Cyan (was #4d908e)
'#7c5fc4', // Deep Purple (was #a78bfa)
];
/** Enhanced live pulse indicator with multi-ring animation */
function LivePulse() {
return (
<div className="relative flex items-center justify-center w-5 h-5">
{/* Outer ping ring */}
<div
className="absolute w-4 h-4 rounded-full animate-ping opacity-20"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Middle pulse ring */}
<div
className="absolute w-3 h-3 rounded-full animate-pulse opacity-40"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Inner solid dot */}
<div
className="relative w-2 h-2 rounded-full z-10"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
</div>
);
}
/** Inline success/failure badge for provider cards */
function InlineStatsBadge({ success, failure }: { success: number; failure: number }) {
if (success === 0 && failure === 0) {
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<CheckCircle2 className="w-3 h-3 text-emerald-700 dark:text-emerald-500" />
<span className="text-[10px] font-mono font-medium text-emerald-700 dark:text-emerald-500">
{success.toLocaleString()}
</span>
</div>
{failure > 0 && (
<div className="flex items-center gap-0.5">
<XCircle className="w-3 h-3 text-red-700 dark:text-red-500" />
<span className="text-[10px] font-mono font-medium text-red-700 dark:text-red-500">
{failure.toLocaleString()}
</span>
</div>
)}
</div>
);
}
export function AuthMonitor() {
const { data, isLoading, error } = useCliproxyAuth();
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
const { privacyMode } = usePrivacy();
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
// Live countdown showing time since last data update
useEffect(() => {
if (!dataUpdatedAt) return;
const updateTime = () => {
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
if (diff < 60) {
setTimeSinceUpdate(`${diff}s ago`);
} else {
setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`);
}
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, [dataUpdatedAt]);
// Build a map of account email -> usage stats from CLIProxy
const accountStatsMap = useMemo(() => {
if (!statsData?.accountStats) return new Map<string, AccountUsageStats>();
return new Map(Object.entries(statsData.accountStats));
}, [statsData?.accountStats]);
// Transform auth status data into account rows
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
if (!data?.authStatus) {
return {
accounts: [],
totalSuccess: 0,
totalFailure: 0,
totalRequests: 0,
providerStats: [],
};
}
const accountsList: AccountRow[] = [];
const providerMap = new Map<
string,
{ success: number; failure: number; accounts: AccountRow[] }
>();
let tSuccess = 0;
let tFailure = 0;
let colorIndex = 0;
data.authStatus.forEach((status: AuthStatus) => {
const providerKey = status.provider;
if (!providerMap.has(providerKey)) {
providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] });
}
const providerData = providerMap.get(providerKey);
if (!providerData) return;
status.accounts?.forEach((account: OAuthAccount) => {
// Get real stats from CLIProxy - try email first, then id
const accountEmail = account.email || account.id;
const realStats = accountStatsMap.get(accountEmail);
const success = realStats?.successCount ?? 0;
const failure = realStats?.failureCount ?? 0;
tSuccess += success;
tFailure += failure;
providerData.success += success;
providerData.failure += failure;
const row: AccountRow = {
id: account.id,
email: account.email || account.id,
provider: status.provider,
displayName: status.displayName,
isDefault: account.isDefault,
successCount: success,
failureCount: failure,
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
};
accountsList.push(row);
providerData.accounts.push(row);
colorIndex++;
});
});
// Build provider stats array
const providerStatsArr: ProviderStats[] = [];
providerMap.forEach((pData, provider) => {
if (pData.accounts.length === 0) return;
providerStatsArr.push({
provider,
displayName: getProviderDisplayName(provider),
totalRequests: pData.success + pData.failure,
successCount: pData.success,
failureCount: pData.failure,
accountCount: pData.accounts.length,
accounts: pData.accounts,
});
});
providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests);
return {
accounts: accountsList,
totalSuccess: tSuccess,
totalFailure: tFailure,
totalRequests: tSuccess + tFailure,
providerStats: providerStatsArr,
};
}, [data?.authStatus, accountStatsMap]);
const overallSuccessRate =
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
// Get selected provider data for detail view
const selectedProviderData = selectedProvider
? providerStats.find((ps) => ps.provider === selectedProvider)
: null;
if (isLoading || statsLoading) {
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="p-4 space-y-4">
<div className="flex gap-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 flex-1 rounded-lg" />
))}
</div>
<Skeleton className="h-48 w-full rounded-lg" />
</div>
</div>
);
}
if (error || !data?.authStatus || accounts.length === 0) {
return null;
}
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
{/* Enhanced Live Header with gradient glow */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
<div className="flex items-center gap-2">
<LivePulse />
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
</div>
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Radio className="w-3 h-3 animate-pulse" />
<span>Updated {timeSinceUpdate || 'now'}</span>
</div>
<span className="text-muted-foreground/50">|</span>
<span>{accounts.length} accounts</span>
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
</div>
</div>
{/* Summary Stats Row */}
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Accounts"
value={accounts.length}
color="var(--accent)"
/>
<SummaryCard
icon={<CheckCircle2 className="w-4 h-4" />}
label="Success"
value={totalSuccess.toLocaleString()}
color={STATUS_COLORS.success}
/>
<SummaryCard
icon={<XCircle className="w-4 h-4" />}
label="Failed"
value={totalFailure.toLocaleString()}
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
/>
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Success Rate"
value={`${overallSuccessRate}%`}
color={
overallSuccessRate === 100
? STATUS_COLORS.success
: overallSuccessRate >= 95
? STATUS_COLORS.degraded
: STATUS_COLORS.failed
}
/>
</div>
{/* Flow Visualization */}
<div className="relative overflow-hidden">
{selectedProviderData ? (
// Account-level flow view
<AccountFlowViz
providerData={selectedProviderData}
onBack={() => setSelectedProvider(null)}
/>
) : (
// Provider cards view
<div className="p-6">
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mb-4">
Request Distribution by Provider
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{providerStats.map((ps) => {
const successRate = getSuccessRate(ps.successCount, ps.failureCount);
const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280';
const isHovered = hoveredProvider === ps.provider;
return (
<button
key={ps.provider}
onClick={() => setSelectedProvider(ps.provider)}
onMouseEnter={() => setHoveredProvider(ps.provider)}
onMouseLeave={() => setHoveredProvider(null)}
className={cn(
'group relative rounded-xl p-4 text-left transition-all duration-300',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'hover:border-opacity-50 hover:scale-[1.02] hover:shadow-lg',
isHovered && 'ring-1'
)}
style={
{
borderColor: isHovered ? providerColor : undefined,
'--ring-color': providerColor,
} as React.CSSProperties
}
>
<div className="flex items-center gap-3 mb-3">
<ProviderIcon provider={ps.provider} size={36} withBackground />
<div>
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{ps.displayName}
</h3>
<p className="text-[10px] text-muted-foreground">
{ps.accountCount} account{ps.accountCount !== 1 ? 's' : ''}
</p>
</div>
<ChevronRight
className={cn(
'w-4 h-4 ml-auto text-muted-foreground transition-all',
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
)}
/>
</div>
<div className="space-y-2">
{/* Inline success/failure stats - immediately visible */}
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Stats</span>
<InlineStatsBadge success={ps.successCount} failure={ps.failureCount} />
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Success Rate</span>
<span
className="font-mono font-semibold"
style={{
color:
successRate === 100
? STATUS_COLORS.success
: successRate >= 95
? STATUS_COLORS.degraded
: STATUS_COLORS.failed,
}}
>
{successRate}%
</span>
</div>
{/* Progress bar */}
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${successRate}%`,
backgroundColor: providerColor,
}}
/>
</div>
</div>
{/* Account color dots */}
<div className="flex gap-1 mt-3">
{ps.accounts.slice(0, 5).map((acc) => (
<div
key={acc.id}
className="w-2 h-2 rounded-full"
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/>
))}
{ps.accounts.length > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{ps.accounts.length - 5}
</span>
)}
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
// Summary Card Component
function SummaryCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
color?: string;
}) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-card/50 dark:bg-zinc-900/50 border border-border/50 dark:border-white/[0.06]">
<div
className="w-8 h-8 rounded-md flex items-center justify-center"
style={{
backgroundColor: color ? `${color}15` : 'var(--muted)',
color: color || 'var(--muted-foreground)',
}}
>
{icon}
</div>
<div>
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</div>
<div
className="text-lg font-semibold font-mono leading-tight"
style={{ color: color || 'var(--foreground)' }}
>
{value}
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
/**
* Error Log Item Component
* Individual log entry in the list view
*/
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { Clock, FileText } from 'lucide-react';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { parseFilename, formatRelativeTime, formatBytes } from '@/lib/error-log-parser';
import type { ErrorLogItemProps } from './types';
export function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
const parsed = useMemo(() => parseFilename(name), [name]);
return (
<button
onClick={onClick}
className={cn(
'w-full px-3 py-2.5 flex items-start gap-3 text-left transition-colors',
'hover:bg-muted/40 border-l-[3px]',
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
)}
>
{/* Provider Icon */}
<ProviderIcon
provider={parsed.provider}
size={24}
withBackground={true}
className="shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0 space-y-1">
{/* Provider / Endpoint */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-foreground truncate">
{parsed.provider}
</span>
<span
className={cn(
'text-[9px] px-1 rounded border',
isSelected
? 'bg-amber-500/10 text-amber-600 border-amber-500/20'
: 'bg-muted border-border text-muted-foreground'
)}
>
LOG
</span>
</div>
<span
className="text-[11px] text-muted-foreground truncate font-medium"
title={parsed.endpoint}
>
{parsed.endpoint}
</span>
</div>
{/* Meta row: time + size */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/80 mt-1">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatRelativeTime(modified)}
</span>
<span className="flex items-center gap-1">
<FileText className="w-3 h-3" />
{formatBytes(size)}
</span>
</div>
</div>
</button>
);
}
@@ -0,0 +1,219 @@
/**
* Error Logs Monitor Component
*
* Displays CLIProxyAPI error logs with master-detail split view.
* ETL: Parses raw logs into structured data for rich display.
* - Left panel: Log list with status code, provider, endpoint, relative time
* - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw)
*/
import { useState, useMemo, useRef, useEffect } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useCliproxyErrorLogs } from '@/hooks/use-cliproxy-stats';
import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react';
import { ErrorLogItem } from './error-log-item';
import { LogContentPanel } from './log-content-panel';
export function ErrorLogsMonitor() {
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
// Vertical resize state
const [height, setHeight] = useState(500);
const [isResizing, setIsResizing] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Auto-scroll handler
const stopAutoScroll = () => {
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
scrollIntervalRef.current = null;
}
};
// Resize handlers
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const containerTopDoc = rect.top + window.scrollY;
const newHeight = e.pageY - containerTopDoc;
// Constrain height (min 300, no max)
setHeight(Math.max(300, newHeight));
// Auto-scroll logic
const viewportHeight = window.innerHeight;
const distFromBottom = viewportHeight - e.clientY;
const scrollSpeed = 15;
stopAutoScroll();
if (distFromBottom < 50) {
scrollIntervalRef.current = setInterval(() => {
window.scrollBy(0, scrollSpeed);
}, 16);
} else if (e.clientY < 50) {
scrollIntervalRef.current = setInterval(() => {
window.scrollBy(0, -scrollSpeed);
}, 16);
}
};
const handleMouseUp = () => {
setIsResizing(false);
stopAutoScroll();
document.body.style.cursor = 'default';
document.body.style.userSelect = 'auto';
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'default';
document.body.style.userSelect = 'auto';
stopAutoScroll();
};
}, [isResizing]);
const startResizing = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
};
// Compute default selection (first log name or null)
const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]);
// Use controlled selection that defaults to first log
const [selectedLog, setSelectedLog] = useState<string | null>(null);
// Effective selection: use user selection if available, otherwise default
const effectiveSelection = selectedLog ?? defaultLogName;
// Get absolute path for the selected log
const selectedAbsolutePath = useMemo(() => {
if (!effectiveSelection || !logs) return undefined;
const log = logs.find((l) => l.name === effectiveSelection);
return log?.absolutePath;
}, [effectiveSelection, logs]);
// Guards
if (isStatusLoading) return null;
if (!status?.running) return null;
if (isLoading) {
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-sm bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm h-[500px]">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="p-4 space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
</div>
);
}
if (!logs || logs.length === 0) return null;
const errorCount = logs.length;
return (
<div
ref={containerRef}
className="rounded-xl border border-border overflow-hidden font-mono text-sm text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm flex flex-col shadow-sm transition-[height] duration-0 ease-linear relative group/container"
style={{ height }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
<div className="flex items-center gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-sm font-semibold tracking-tight">Error Logs</span>
<span className="text-xs text-muted-foreground ml-1">
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileWarning className="w-3.5 h-3.5" />
<span>CLIProxy Diagnostics</span>
</div>
</div>
{/* Resizable Panel Layout */}
<div className="flex-1 min-h-0">
<PanelGroup direction="horizontal">
{/* Left Panel: Log List */}
<Panel defaultSize={30} minSize={20} maxSize={50} className="flex flex-col min-w-0">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{logs.slice(0, 50).map((log) => (
<ErrorLogItem
key={log.name}
name={log.name}
size={log.size}
modified={log.modified}
isSelected={effectiveSelection === log.name}
onClick={() => setSelectedLog(log.name)}
/>
))}
</div>
{logs.length > 50 && (
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
Showing 50 of {logs.length} logs
</div>
)}
</ScrollArea>
</Panel>
{/* Resize Handle */}
<PanelResizeHandle className="w-[1px] bg-border hover:bg-primary/50 transition-colors flex items-center justify-center group relative z-10 w-2 -ml-1 flex items-center justify-center outline-none">
<div className="w-[1px] h-full bg-border group-hover:bg-primary/50 transition-colors" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 rounded-sm flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-muted border border-border">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
</PanelResizeHandle>
{/* Right Panel: Log Content */}
<Panel className="flex flex-col min-w-0 bg-background/50">
<LogContentPanel name={effectiveSelection} absolutePath={selectedAbsolutePath} />
</Panel>
</PanelGroup>
</div>
{/* Use standard footer if error, otherwise show resize handle */}
{error ? (
<div className="px-4 py-2 border-t border-border text-xs text-destructive bg-destructive/5 shrink-0">
{error.message}
</div>
) : (
<div
className="h-2 bg-border/10 border-t border-border/30 hover:bg-primary/10 transition-colors cursor-row-resize flex items-center justify-center group/handle shrink-0"
onMouseDown={startResizing}
>
<GripHorizontal className="w-8 h-3 text-border group-hover:text-primary/50 transition-colors" />
</div>
)}
</div>
);
}
// Re-export components
export { ErrorLogItem } from './error-log-item';
export { LogContentPanel } from './log-content-panel';
export { TabButton, StatusBadge } from './ui-primitives';
export { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components';
export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './types';
@@ -0,0 +1,140 @@
/**
* Log Content Panel Component
* Detail view with tabbed navigation for error log content
*/
import { useState, useMemo } from 'react';
import { useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
import { Skeleton } from '@/components/ui/skeleton';
import { CopyButton } from '@/components/ui/copy-button';
import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react';
import { parseErrorLog } from '@/lib/error-log-parser';
import type { TabType, LogContentPanelProps } from './types';
import { TabButton, StatusBadge } from './ui-primitives';
import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components';
export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
const [activeTab, setActiveTab] = useState<TabType>('overview');
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
// Parse log content
const parsed = useMemo(() => {
if (!content) return null;
return parseErrorLog(content);
}, [content]);
// No log selected
if (!name) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-3">
<Terminal className="w-10 h-10 mx-auto opacity-40" />
<p className="text-sm">Select a log to view details</p>
</div>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className="flex-1 p-6 space-y-3">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-5/6" />
<Skeleton className="h-5 w-2/3" />
</div>
);
}
// Error or no content
if (error || !content || !parsed) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="text-sm">Failed to load log content</p>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-w-0 h-full">
{/* Header with status */}
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<StatusBadge code={parsed.statusCode} />
<span className="text-xs font-semibold truncate text-foreground">
{parsed.provider}/{parsed.endpoint || 'unknown'}
</span>
{/* Copy Absolute Path Button */}
{name && (
<CopyButton
value={absolutePath || name}
label="Copy absolute path"
size="icon-sm"
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
/>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Copy Raw Content Button */}
{content && (
<CopyButton
value={content}
label="Copy raw log content"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
/>
)}
<span className="text-[10px] text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded border border-border/50">
{parsed.method}
</span>
</div>
</div>
{/* Tabs */}
<div className="px-3 py-2 border-b border-border flex items-center gap-1 bg-muted/10 shrink-0 overflow-x-auto">
<TabButton
active={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
icon={Info}
>
Overview
</TabButton>
<TabButton
active={activeTab === 'headers'}
onClick={() => setActiveTab('headers')}
icon={Code}
>
Headers
</TabButton>
<TabButton
active={activeTab === 'request'}
onClick={() => setActiveTab('request')}
icon={ArrowUpRight}
>
Request
</TabButton>
<TabButton
active={activeTab === 'response'}
onClick={() => setActiveTab('response')}
icon={ArrowDownLeft}
>
Response
</TabButton>
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
Raw
</TabButton>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden bg-card/30">
{activeTab === 'overview' && <OverviewTab parsed={parsed} />}
{activeTab === 'headers' && <HeadersTab headers={parsed.requestHeaders} />}
{activeTab === 'request' && <BodyTab content={parsed.requestBody} label="Request" />}
{activeTab === 'response' && <BodyTab content={parsed.responseBody} label="Response" />}
{activeTab === 'raw' && <RawTab content={content} />}
</div>
</div>
);
}
@@ -0,0 +1,149 @@
/**
* Tab Content Components for Error Logs Monitor
* OverviewTab, HeadersTab, BodyTab, RawTab
*/
import { ScrollArea } from '@/components/ui/scroll-area';
import { Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getErrorTypeLabel, type ParsedErrorLog } from '@/lib/error-log-parser';
import { StatusBadge } from './ui-primitives';
/** Overview tab content */
export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
return (
<div className="p-4 space-y-4">
{/* Status row */}
<div className="flex items-center gap-3">
<StatusBadge code={parsed.statusCode} />
<span className="text-sm font-medium">{parsed.statusText}</span>
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
{getErrorTypeLabel(parsed.errorType)}
</span>
</div>
{/* Key metrics grid */}
<div className="grid grid-cols-4 gap-3 text-xs">
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Method</div>
<div className="font-medium">{parsed.method || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Provider</div>
<div className="font-medium">{parsed.provider || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Version</div>
<div className="font-medium">{parsed.version || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Endpoint</div>
<div className="font-medium truncate" title={parsed.endpoint}>
{parsed.endpoint || 'N/A'}
</div>
</div>
</div>
{/* URL */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">URL</div>
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
{parsed.url || 'N/A'}
</div>
</div>
{/* Timestamp */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">Timestamp</div>
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
</div>
{/* Suggestion based on error type */}
{parsed.errorType !== 'unknown' && (
<div className="flex items-start gap-3 p-3 rounded bg-blue-500/10 border border-blue-500/20 text-xs">
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
<div className="text-blue-500/90 leading-relaxed">
{parsed.errorType === 'rate_limit' &&
'Rate limited. Consider using multiple accounts or reducing request frequency.'}
{parsed.errorType === 'auth' &&
'Authentication failed. Check credentials or re-authenticate with the provider.'}
{parsed.errorType === 'not_found' &&
'Endpoint not found. This endpoint may not exist on this provider.'}
{parsed.errorType === 'server' &&
'Server error from upstream. Retry or check provider status.'}
{parsed.errorType === 'timeout' &&
'Request timed out. Check network or increase timeout settings.'}
</div>
</div>
)}
</div>
);
}
/** Headers tab content */
export function HeadersTab({ headers }: { headers: Record<string, string> }) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return <div className="p-4 text-xs text-muted-foreground">No headers available</div>;
}
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-1">
{entries.map(([key, value]) => (
<div
key={key}
className="flex gap-3 text-xs font-mono py-1.5 border-b border-border/30 last:border-0"
>
<span className="text-muted-foreground shrink-0 min-w-[140px]">{key}:</span>
<span className="break-all">{value}</span>
</div>
))}
</div>
</ScrollArea>
);
}
/** JSON/Body tab content */
export function BodyTab({ content, label }: { content: string; label: string }) {
if (!content || content.trim() === '') {
return <div className="p-4 text-xs text-muted-foreground">No {label.toLowerCase()} body</div>;
}
// Try to format as JSON
let formatted = content;
let isJson = false;
try {
const parsed = JSON.parse(content);
formatted = JSON.stringify(parsed, null, 2);
isJson = true;
} catch {
// Not JSON, use as-is
}
return (
<ScrollArea className="h-full">
<pre
className={cn(
'p-4 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed',
isJson
? 'text-emerald-700 dark:text-green-400'
: 'text-zinc-700 dark:text-muted-foreground'
)}
>
{formatted}
</pre>
</ScrollArea>
);
}
/** Raw tab content */
export function RawTab({ content }: { content: string }) {
return (
<ScrollArea className="h-full">
<pre className="p-4 text-xs font-mono text-zinc-700 dark:text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
{content}
</pre>
</ScrollArea>
);
}
@@ -0,0 +1,18 @@
/**
* Types for Error Logs Monitor
*/
export type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw';
export interface ErrorLogItemProps {
name: string;
size: number;
modified: number;
isSelected: boolean;
onClick: () => void;
}
export interface LogContentPanelProps {
name: string | null;
absolutePath?: string;
}
@@ -0,0 +1,51 @@
/**
* UI Primitives for Error Logs Monitor
* TabButton and StatusBadge components
*/
import { cn } from '@/lib/utils';
import { getStatusColor } from '@/lib/error-log-parser';
/** Tab button component */
export function TabButton({
active,
onClick,
children,
icon: Icon,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<button
onClick={onClick}
className={cn(
'px-2.5 py-1.5 text-xs font-medium rounded transition-colors flex items-center gap-1.5',
active
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
{Icon && <Icon className="w-3.5 h-3.5" />}
{children}
</button>
);
}
/** Status badge component */
export function StatusBadge({ code }: { code: number }) {
const colorClass = getStatusColor(code);
return (
<span
className={cn(
'inline-flex items-center justify-center min-w-[36px] px-2 py-0.5 rounded text-xs font-bold',
'bg-current/10 border border-current/20',
colorClass
)}
>
{code}
</span>
);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Monitoring Components Barrel Export
*/
// Main monitoring components
export { AuthMonitor } from './auth-monitor';
export { ProxyStatusWidget } from './proxy-status-widget';
// Error logs (from subdirectory)
export { ErrorLogsMonitor } from './error-logs';
export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './error-logs';
@@ -0,0 +1,196 @@
/**
* Proxy Status Widget
*
* Displays CLIProxy process status with start/stop/restart controls.
* Shows: running state, port, session count, uptime, update availability.
*/
import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
useProxyStatus,
useStartProxy,
useStopProxy,
useCliproxyUpdateCheck,
} from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils';
function formatUptime(startedAt?: string): string {
if (!startedAt) return '';
const start = new Date(startedAt).getTime();
const now = Date.now();
const diff = now - start;
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function formatTimeAgo(timestamp?: number): string {
if (!timestamp) return '';
const diff = Date.now() - timestamp;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
return `${hours}h ago`;
}
export function ProxyStatusWidget() {
const { data: status, isLoading } = useProxyStatus();
const { data: updateCheck } = useCliproxyUpdateCheck();
const startProxy = useStartProxy();
const stopProxy = useStopProxy();
const isRunning = status?.running ?? false;
const isActioning = startProxy.isPending || stopProxy.isPending;
const hasUpdate = updateCheck?.hasUpdate ?? false;
// Restart = stop then start
const handleRestart = async () => {
await stopProxy.mutateAsync();
// Small delay to ensure port is released
await new Promise((r) => setTimeout(r, 500));
startProxy.mutate();
};
return (
<div
className={cn(
'rounded-lg border p-3 transition-colors',
isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground/30'
)}
/>
<span className="text-sm font-medium">CLIProxy Service</span>
{hasUpdate && (
<Badge
variant="secondary"
className="text-[10px] h-4 px-1.5 gap-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
title={`Update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`}
>
<ArrowUp className="w-2.5 h-2.5" />
Update
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{isLoading ? (
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
) : isRunning ? (
<Activity className="w-3 h-3 text-green-600" />
) : (
<Power className="w-3 h-3 text-muted-foreground" />
)}
</div>
</div>
{isRunning && status ? (
<>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">Port {status.port}</span>
{status.sessionCount !== undefined && status.sessionCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
</span>
)}
{status.startedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatUptime(status.startedAt)}
</span>
)}
</div>
{/* Control buttons when running */}
<div className="mt-2 flex items-center gap-2">
<Button
variant={hasUpdate ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 text-xs gap-1 flex-1',
hasUpdate &&
'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
)}
onClick={handleRestart}
disabled={isActioning}
title={
hasUpdate
? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`
: 'Restart CLIProxy service'
}
>
{isActioning ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : hasUpdate ? (
<ArrowUp className="w-3 h-3" />
) : (
<RotateCw className="w-3 h-3" />
)}
{hasUpdate ? 'Update' : 'Restart'}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30"
onClick={() => stopProxy.mutate()}
disabled={isActioning}
title="Stop CLIProxy service"
>
{stopProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Square className="w-3 h-3" />
)}
Stop
</Button>
</div>
</>
) : (
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Not running</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => startProxy.mutate()}
disabled={startProxy.isPending}
>
{startProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Power className="w-3 h-3" />
)}
Start
</Button>
</div>
)}
{/* Version sync indicator */}
{updateCheck?.currentVersion && (
<div className="mt-2 pt-2 border-t border-muted flex items-center justify-between text-[10px] text-muted-foreground/70">
<span>v{updateCheck.currentVersion}</span>
{updateCheck.checkedAt && (
<span title={new Date(updateCheck.checkedAt).toLocaleString()}>
Synced {formatTimeAgo(updateCheck.checkedAt)}
</span>
)}
</div>
)}
</div>
);
}