mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 00:16:46 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user