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
*
* Displays CLIProxyAPI error logs with expandable details.
* Designed to complement the AuthMonitor on the Home page.
* Displays CLIProxyAPI error logs with master-detail split view.
* 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 { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
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 {
AlertTriangle,
ChevronDown,
ChevronRight,
FileWarning,
Clock,
FileText,
XCircle,
FlaskConical,
Terminal,
} 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 */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -39,7 +110,6 @@ function formatRelativeTime(unixSeconds: number): string {
/** Parse error log filename to extract endpoint and timestamp */
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$/);
if (match) {
const endpoint = match[1].replace(/-/g, '/');
@@ -49,84 +119,181 @@ function parseErrorLogName(name: string): { endpoint: string; timestamp: string
return { endpoint: name, timestamp: '' };
}
/** Error log content viewer with syntax highlighting */
function ErrorLogContent({ name }: { name: string }) {
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
/** Log content panel component */
function LogContentPanel({ name, demo = false }: { name: string | null; demo?: boolean }) {
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) {
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-3/4" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
);
}
// Error or no content
if (error || !content) {
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 (
<ScrollArea className="h-[200px] rounded-md">
<pre className="p-4 text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
{content}
</pre>
</ScrollArea>
<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">
{content}
</pre>
</ScrollArea>
</div>
);
}
export function ErrorLogsMonitor() {
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
const [expandedLog, setExpandedLog] = useState<string | null>(null);
/** Error log item in the list */
interface ErrorLogItemProps {
name: string;
size: number;
modified: number;
isSelected: boolean;
onClick: () => void;
}
// Don't show while status is loading or if proxy not running
if (isStatusLoading) {
return null;
}
function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
const { endpoint, timestamp } = parseErrorLogName(name);
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" />
return (
<button
onClick={onClick}
className={cn(
'w-full px-3 py-2.5 flex items-start gap-2 text-left transition-colors',
'hover:bg-muted/40 border-l-2',
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
)}
>
<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 className="p-4 space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
<div className="flex items-center gap-2 text-[9px] text-muted-foreground">
<span className="flex items-center gap-0.5">
<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>
{timestamp && (
<div className="text-[9px] text-muted-foreground/70 truncate">{timestamp}</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)
if (!logs || logs.length === 0) {
return null;
}
const errorCount = logs.length;
const errorCount = displayLogs?.length ?? 0;
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">
{/* 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 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 }} />
</div>
<span className="text-xs font-semibold tracking-tight text-foreground">Error Logs</span>
<AlertTriangle className="w-4 h-4" style={{ color: STATUS_COLORS.degraded }} />
<span className="text-xs font-semibold tracking-tight">Error Logs</span>
<span className="text-[10px] text-muted-foreground">
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
</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 className="flex items-center gap-2 text-[10px] text-muted-foreground">
<FileWarning className="w-3 h-3" />
@@ -134,78 +301,36 @@ export function ErrorLogsMonitor() {
</div>
</div>
{/* Error logs list */}
<ScrollArea className="max-h-[300px]">
<div className="divide-y divide-border">
{logs.slice(0, 10).map((log) => {
const isExpanded = expandedLog === log.name;
const { endpoint, timestamp } = parseErrorLogName(log.name);
return (
<div key={log.name} className="bg-background/50">
<button
onClick={() => setExpandedLog(isExpanded ? null : log.name)}
className={cn(
'w-full px-4 py-3 flex items-center gap-3 text-left transition-colors',
'hover:bg-muted/30',
isExpanded && 'bg-muted/20'
)}
>
{/* Expand icon */}
<div className="shrink-0">
{isExpanded ? (
<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>
)}
{/* Split View: List (left) + Content (right) */}
<div className="flex h-[280px]">
{/* Left Panel: Log List */}
<div className="w-[240px] shrink-0 border-r border-border">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{displayLogs?.slice(0, 10).map((log) => (
<ErrorLogItem
key={log.name}
name={log.name}
size={log.size}
modified={log.modified}
isSelected={effectiveSelection === log.name}
onClick={() => setSelectedLog(log.name)}
/>
))}
</div>
{(displayLogs?.length ?? 0) > 10 && (
<div className="px-3 py-2 text-center text-[9px] text-muted-foreground border-t border-border/50">
Showing 10 of {displayLogs?.length} logs
</div>
);
})}
)}
</ScrollArea>
</div>
{/* Show more indicator */}
{logs.length > 10 && (
<div className="px-4 py-2 text-center text-[10px] text-muted-foreground border-t border-border">
Showing 10 of {logs.length} error logs
</div>
)}
</ScrollArea>
{/* Right Panel: Log Content */}
<LogContentPanel name={effectiveSelection} demo={demo} />
</div>
{/* Footer hint */}
{/* Footer error */}
{error && (
<div className="px-4 py-2 border-t border-border text-[10px] text-destructive">
{error.message}