feat(ui): redesign error logs monitor with split view layout

- Replace dropdown accordion with master-detail split view
- Add log list panel (240px) on left with selection state
- Add content viewer panel on right for better readability
- Add demo mode prop for UI testing
- Auto-select first log via useMemo/derived state
This commit is contained in:
kaitranntt
2025-12-18 18:23:38 -05:00
parent 6b9396fbc6
commit 8f47b8775f
+243 -118
View File
@@ -1,11 +1,11 @@
/** /**
* Error Logs Monitor Component * Error Logs Monitor Component
* *
* Displays CLIProxyAPI error logs with expandable details. * Displays CLIProxyAPI error logs with master-detail split view.
* Designed to complement the AuthMonitor on the Home page. * Log list on left, content panel on right for better readability.
*/ */
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { cn, STATUS_COLORS } from '@/lib/utils'; import { cn, STATUS_COLORS } from '@/lib/utils';
@@ -13,14 +13,85 @@ import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
AlertTriangle, AlertTriangle,
ChevronDown,
ChevronRight,
FileWarning, FileWarning,
Clock, Clock,
FileText, FileText,
XCircle, XCircle,
FlaskConical,
Terminal,
} from 'lucide-react'; } from 'lucide-react';
/** Demo mode mock data */
const DEMO_ERROR_LOGS = [
{
name: 'error-v1-chat-completions-2025-12-18T17-45-23.log',
size: 4523,
modified: Math.floor(Date.now() / 1000) - 120,
},
{
name: 'error-v1-messages-2025-12-18T17-30-15.log',
size: 8912,
modified: Math.floor(Date.now() / 1000) - 900,
},
{
name: 'error-v1-chat-completions-2025-12-18T16-22-08.log',
size: 2341,
modified: Math.floor(Date.now() / 1000) - 5400,
},
{
name: 'error-v1-models-2025-12-18T14-10-55.log',
size: 1024,
modified: Math.floor(Date.now() / 1000) - 12600,
},
];
const DEMO_LOG_CONTENT = `================================================================================
REQUEST ERROR LOG
================================================================================
Timestamp: 2025-12-18T17:45:23Z
Endpoint: /v1/chat/completions
Method: POST
Status: 429 Too Many Requests
--------------------------------------------------------------------------------
REQUEST HEADERS
--------------------------------------------------------------------------------
Content-Type: application/json
Authorization: Bearer ccs-internal-managed
User-Agent: claude-code/1.0
--------------------------------------------------------------------------------
REQUEST BODY
--------------------------------------------------------------------------------
{
"model": "gemini-claude-opus-4-5-thinking",
"messages": [
{"role": "user", "content": "Explain quantum computing"}
],
"max_tokens": 4096,
"stream": true
}
--------------------------------------------------------------------------------
RESPONSE
--------------------------------------------------------------------------------
{
"error": {
"message": "Rate limit exceeded. Please retry after 60 seconds.",
"type": "rate_limit_error",
"code": "rate_limit_exceeded"
}
}
--------------------------------------------------------------------------------
UPSTREAM API ERROR
--------------------------------------------------------------------------------
Provider: gemini
Account: user@gmail.com
Quota: Daily limit reached (1500/1500 requests)
Retry-After: 60
================================================================================`;
/** Format file size in human-readable format */ /** Format file size in human-readable format */
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -39,7 +110,6 @@ function formatRelativeTime(unixSeconds: number): string {
/** Parse error log filename to extract endpoint and timestamp */ /** Parse error log filename to extract endpoint and timestamp */
function parseErrorLogName(name: string): { endpoint: string; timestamp: string } { function parseErrorLogName(name: string): { endpoint: string; timestamp: string } {
// Format: error-v1-chat-completions-2025-01-15T10-30-00.log
const match = name.match(/^error-(.+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.log$/); const match = name.match(/^error-(.+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.log$/);
if (match) { if (match) {
const endpoint = match[1].replace(/-/g, '/'); const endpoint = match[1].replace(/-/g, '/');
@@ -49,84 +119,181 @@ function parseErrorLogName(name: string): { endpoint: string; timestamp: string
return { endpoint: name, timestamp: '' }; return { endpoint: name, timestamp: '' };
} }
/** Error log content viewer with syntax highlighting */ /** Log content panel component */
function ErrorLogContent({ name }: { name: string }) { function LogContentPanel({ name, demo = false }: { name: string | null; demo?: boolean }) {
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); const { data: content, isLoading, error } = useCliproxyErrorLogContent(demo ? null : name);
// No log selected
if (!name) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<Terminal className="w-8 h-8 mx-auto opacity-40" />
<p className="text-xs">Select a log to view details</p>
</div>
</div>
);
}
// Demo mode
if (demo) {
const { endpoint } = parseErrorLogName(name);
return (
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b border-border bg-muted/30 flex items-center gap-2">
<Terminal className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium truncate">{endpoint}</span>
</div>
<ScrollArea className="flex-1">
<pre className="p-3 text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
{DEMO_LOG_CONTENT}
</pre>
</ScrollArea>
</div>
);
}
// Loading state
if (isLoading) { if (isLoading) {
return ( return (
<div className="p-4 space-y-2"> <div className="flex-1 p-4 space-y-2">
<Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" /> <Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div> </div>
); );
} }
// Error or no content
if (error || !content) { if (error || !content) {
return ( return (
<div className="p-4 text-xs text-muted-foreground">Failed to load error log content</div> <div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="text-xs">Failed to load log content</p>
</div>
); );
} }
// Show content
const { endpoint } = parseErrorLogName(name);
return ( return (
<ScrollArea className="h-[200px] rounded-md"> <div className="flex-1 flex flex-col min-w-0">
<pre className="p-4 text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed"> <div className="px-3 py-2 border-b border-border bg-muted/30 flex items-center gap-2">
{content} <Terminal className="w-3.5 h-3.5 text-muted-foreground" />
</pre> <span className="text-[11px] font-medium truncate">{endpoint}</span>
</ScrollArea> </div>
<ScrollArea className="flex-1">
<pre className="p-3 text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
{content}
</pre>
</ScrollArea>
</div>
); );
} }
export function ErrorLogsMonitor() { /** Error log item in the list */
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); interface ErrorLogItemProps {
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); name: string;
const [expandedLog, setExpandedLog] = useState<string | null>(null); size: number;
modified: number;
isSelected: boolean;
onClick: () => void;
}
// Don't show while status is loading or if proxy not running function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
if (isStatusLoading) { const { endpoint, timestamp } = parseErrorLogName(name);
return null;
}
if (!status?.running) { return (
return null; <button
} onClick={onClick}
className={cn(
if (isLoading) { 'w-full px-3 py-2.5 flex items-start gap-2 text-left transition-colors',
return ( 'hover:bg-muted/40 border-l-2',
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm"> isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border"> )}
<Skeleton className="h-4 w-32" /> >
<Skeleton className="h-4 w-20" /> <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: STATUS_COLORS.failed }} />
<div className="flex-1 min-w-0 space-y-0.5">
<div className="text-[11px] font-medium truncate" title={endpoint}>
{endpoint}
</div> </div>
<div className="p-4 space-y-2"> <div className="flex items-center gap-2 text-[9px] text-muted-foreground">
{[1, 2, 3].map((i) => ( <span className="flex items-center gap-0.5">
<Skeleton key={i} className="h-10 w-full rounded-lg" /> <Clock className="w-2.5 h-2.5" />
))} {formatRelativeTime(modified)}
</span>
<span className="flex items-center gap-0.5">
<FileText className="w-2.5 h-2.5" />
{formatSize(size)}
</span>
</div> </div>
{timestamp && (
<div className="text-[9px] text-muted-foreground/70 truncate">{timestamp}</div>
)}
</div> </div>
); </button>
);
}
export function ErrorLogsMonitor({ demo = false }: { demo?: boolean } = {}) {
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
const {
data: logs,
isLoading,
error,
} = useCliproxyErrorLogs(demo ? false : (status?.running ?? false));
// Use demo data or real data
const displayLogs = demo ? DEMO_ERROR_LOGS : logs;
// Compute default selection (first log name or null)
const defaultLogName = useMemo(() => displayLogs?.[0]?.name ?? null, [displayLogs]);
// 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;
// Non-demo mode guards
if (!demo) {
if (isStatusLoading) return null;
if (!status?.running) return null;
if (isLoading) {
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-2.5 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="p-4 space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
</div>
);
}
if (!logs || logs.length === 0) return null;
} }
// Don't show if no errors (good state) const errorCount = displayLogs?.length ?? 0;
if (!logs || logs.length === 0) {
return null;
}
const errorCount = logs.length;
return ( 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"> <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">
{/* Header with warning styling */} {/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15"> <div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex items-center justify-center w-5 h-5"> <AlertTriangle className="w-4 h-4" style={{ color: STATUS_COLORS.degraded }} />
<AlertTriangle className="w-4 h-4" style={{ color: STATUS_COLORS.degraded }} /> <span className="text-xs font-semibold tracking-tight">Error Logs</span>
</div>
<span className="text-xs font-semibold tracking-tight text-foreground">Error Logs</span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{errorCount} failed request{errorCount !== 1 ? 's' : ''} {errorCount} failed request{errorCount !== 1 ? 's' : ''}
</span> </span>
{demo && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium bg-violet-500/20 text-violet-400 border border-violet-500/30">
<FlaskConical className="w-2.5 h-2.5" />
DEMO
</span>
)}
</div> </div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground"> <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<FileWarning className="w-3 h-3" /> <FileWarning className="w-3 h-3" />
@@ -134,78 +301,36 @@ export function ErrorLogsMonitor() {
</div> </div>
</div> </div>
{/* Error logs list */} {/* Split View: List (left) + Content (right) */}
<ScrollArea className="max-h-[300px]"> <div className="flex h-[280px]">
<div className="divide-y divide-border"> {/* Left Panel: Log List */}
{logs.slice(0, 10).map((log) => { <div className="w-[240px] shrink-0 border-r border-border">
const isExpanded = expandedLog === log.name; <ScrollArea className="h-full">
const { endpoint, timestamp } = parseErrorLogName(log.name); <div className="divide-y divide-border/50">
{displayLogs?.slice(0, 10).map((log) => (
return ( <ErrorLogItem
<div key={log.name} className="bg-background/50"> key={log.name}
<button name={log.name}
onClick={() => setExpandedLog(isExpanded ? null : log.name)} size={log.size}
className={cn( modified={log.modified}
'w-full px-4 py-3 flex items-center gap-3 text-left transition-colors', isSelected={effectiveSelection === log.name}
'hover:bg-muted/30', onClick={() => setSelectedLog(log.name)}
isExpanded && 'bg-muted/20' />
)} ))}
> </div>
{/* Expand icon */} {(displayLogs?.length ?? 0) > 10 && (
<div className="shrink-0"> <div className="px-3 py-2 text-center text-[9px] text-muted-foreground border-t border-border/50">
{isExpanded ? ( Showing 10 of {displayLogs?.length} logs
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</div>
{/* Error indicator */}
<XCircle className="w-4 h-4 shrink-0" style={{ color: STATUS_COLORS.failed }} />
{/* Endpoint */}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate" title={endpoint}>
{endpoint}
</div>
{timestamp && (
<div className="text-[10px] text-muted-foreground">{timestamp}</div>
)}
</div>
{/* Metadata */}
<div className="flex items-center gap-3 shrink-0 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="w-3 h-3" />
<span>{formatSize(log.size)}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatRelativeTime(log.modified)}</span>
</div>
</div>
</button>
{/* Expandable content */}
{isExpanded && (
<div className="border-t border-border bg-muted/10">
<ErrorLogContent name={log.name} />
</div>
)}
</div> </div>
); )}
})} </ScrollArea>
</div> </div>
{/* Show more indicator */} {/* Right Panel: Log Content */}
{logs.length > 10 && ( <LogContentPanel name={effectiveSelection} demo={demo} />
<div className="px-4 py-2 text-center text-[10px] text-muted-foreground border-t border-border"> </div>
Showing 10 of {logs.length} error logs
</div>
)}
</ScrollArea>
{/* Footer hint */} {/* Footer error */}
{error && ( {error && (
<div className="px-4 py-2 border-t border-border text-[10px] text-destructive"> <div className="px-4 py-2 border-t border-border text-[10px] text-destructive">
{error.message} {error.message}