feat(ui): add absolute path copy for error logs

- add absolutePath field to CliproxyErrorLog interface
- inject absolute path in routes.ts from getCliproxyWritablePath()
- update copy button to use absolute path with fallback to filename
- refactor error-logs-monitor to remove demo mode
- add error-log-parser lib for structured log parsing
This commit is contained in:
kaitranntt
2025-12-18 21:06:20 -05:00
parent 78a14e873d
commit 5d4f49e4bb
6 changed files with 850 additions and 235 deletions
+2
View File
@@ -279,6 +279,8 @@ export interface CliproxyErrorLog {
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Absolute path to the log file (injected by backend) */
absolutePath?: string;
}
/** Response from /v0/management/request-error-logs endpoint */
+9 -1
View File
@@ -25,6 +25,7 @@ import {
fetchCliproxyErrorLogs,
fetchCliproxyErrorLogContent,
} from '../cliproxy/stats-fetcher';
import { getCliproxyWritablePath } from '../cliproxy/config-generator';
import {
listOpenAICompatProviders,
getOpenAICompatProvider,
@@ -1446,7 +1447,14 @@ apiRoutes.get('/cliproxy/error-logs', async (_req: Request, res: Response): Prom
return;
}
res.json({ files });
// Inject absolute paths into each file entry
const logsDir = path.join(getCliproxyWritablePath(), 'logs');
const filesWithPaths = files.map((file) => ({
...file,
absolutePath: path.join(logsDir, file.name),
}));
res.json({ files: filesWithPaths });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
+501 -225
View File
@@ -2,191 +2,351 @@
* Error Logs Monitor Component
*
* Displays CLIProxyAPI error logs with master-detail split view.
* Log list on left, content panel on right for better readability.
* 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 } from 'react';
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, STATUS_COLORS } from '@/lib/utils';
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,
XCircle,
FlaskConical,
Terminal,
Info,
Code,
ArrowUpRight,
ArrowDownLeft,
GripVertical,
GripHorizontal,
} from 'lucide-react';
import {
parseErrorLog,
parseFilename,
formatRelativeTime,
formatBytes,
getStatusColor,
getErrorTypeLabel,
type ParsedErrorLog,
} from '@/lib/error-log-parser';
/** 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,
},
];
type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw';
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
/** 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>
);
}
--------------------------------------------------------------------------------
RESPONSE
--------------------------------------------------------------------------------
{
"error": {
"message": "Rate limit exceeded. Please retry after 60 seconds.",
"type": "rate_limit_error",
"code": "rate_limit_exceeded"
/** 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>
);
}
--------------------------------------------------------------------------------
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`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Format timestamp to relative time */
function formatRelativeTime(unixSeconds: number): string {
const diff = Math.floor(Date.now() / 1000 - unixSeconds);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
/** Parse error log filename to extract endpoint and timestamp */
function parseErrorLogName(name: string): { endpoint: string; timestamp: string } {
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, '/');
const timestamp = match[2].replace(/T/, ' ').replace(/-/g, ':');
return { endpoint: `/${endpoint}`, timestamp };
/** 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>;
}
return { endpoint: name, timestamp: '' };
// 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>
);
}
/** Log content panel component */
function LogContentPanel({ name, demo = false }: { name: string | null; demo?: boolean }) {
const { data: content, isLoading, error } = useCliproxyErrorLogContent(demo ? null : name);
/** 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-2">
<Terminal className="w-8 h-8 mx-auto opacity-40" />
<p className="text-xs">Select a log to view details</p>
<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>
);
}
// 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="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 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) {
if (error || !content || !parsed) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="text-xs">Failed to load log content</p>
<p className="text-sm">Failed to load log content</p>
</div>
);
}
// Show content
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 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>
<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>
);
}
@@ -201,52 +361,146 @@ interface ErrorLogItemProps {
}
function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
const { endpoint, timestamp } = parseErrorLogName(name);
const parsed = useMemo(() => parseFilename(name), [name]);
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',
'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'
)}
>
<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}
{/* 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>
<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" />
{/* 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-0.5">
<FileText className="w-2.5 h-2.5" />
{formatSize(size)}
<span className="flex items-center gap-1">
<FileText className="w-3 h-3" />
{formatBytes(size)}
</span>
</div>
{timestamp && (
<div className="text-[9px] text-muted-foreground/70 truncate">{timestamp}</div>
)}
</div>
</button>
);
}
export function ErrorLogsMonitor({ demo = false }: { demo?: boolean } = {}) {
export function ErrorLogsMonitor() {
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;
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(() => displayLogs?.[0]?.name ?? null, [displayLogs]);
const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]);
// Use controlled selection that defaults to first log
const [selectedLog, setSelectedLog] = useState<string | null>(null);
@@ -254,87 +508,109 @@ export function ErrorLogsMonitor({ demo = false }: { demo?: boolean } = {}) {
// 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;
}
// 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]);
const errorCount = displayLogs?.length ?? 0;
// 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 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
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-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">
<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">
<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>
{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" />
<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>
{/* 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
{/* 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>
)}
</ScrollArea>
</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>
{/* Right Panel: Log Content */}
<LogContentPanel name={effectiveSelection} demo={demo} />
{/* 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>
{/* Footer error */}
{error && (
<div className="px-4 py-2 border-t border-border text-[10px] text-destructive">
{/* 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>
);
+8 -9
View File
@@ -3,19 +3,21 @@ import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { type VariantProps } from 'class-variance-authority';
import { type buttonVariants } from '@/components/ui/button-variants';
interface CopyButtonProps {
value: string;
className?: string;
variant?: 'default' | 'outline' | 'ghost' | 'secondary';
size?: 'default' | 'sm' | 'lg' | 'icon';
variant?: VariantProps<typeof buttonVariants>['variant'];
size?: VariantProps<typeof buttonVariants>['size'];
label?: string;
}
export function CopyButton({
value,
className,
variant = 'ghost',
variant = 'outline',
size = 'icon',
label = 'Copy to clipboard',
}: CopyButtonProps) {
@@ -34,19 +36,16 @@ export function CopyButton({
<Button
size={size}
variant={variant}
className={cn(
'h-6 w-6 relative z-10 text-foreground/70 hover:text-foreground',
className
)}
className={cn('relative z-10 text-muted-foreground hover:text-foreground', className)}
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
>
{hasCopied ? (
<Check className="h-3 w-3 text-green-500" />
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3 w-3" />
<Copy className="h-3.5 w-3.5" />
)}
<span className="sr-only">{label}</span>
</Button>
+2
View File
@@ -136,6 +136,8 @@ export interface CliproxyErrorLog {
name: string;
size: number;
modified: number;
/** Absolute path to the log file (injected by backend) */
absolutePath?: string;
}
/**
+328
View File
@@ -0,0 +1,328 @@
/**
* Error Log Parser Utility
*
* Parses CLIProxy error log content into structured data for display.
* Extracts request info, headers, body, and response sections.
*/
/** Parsed error log structure */
export interface ParsedErrorLog {
// Request Info
version: string;
url: string;
method: string;
timestamp: string;
// Response
statusCode: number;
statusText: string;
// Sections (raw strings)
requestHeaders: Record<string, string>;
requestBody: string;
responseHeaders: Record<string, string>;
responseBody: string;
// Computed metadata
provider: string;
endpoint: string;
isClientError: boolean;
isServerError: boolean;
errorType: 'rate_limit' | 'auth' | 'not_found' | 'server' | 'timeout' | 'unknown';
}
/** Parsed filename metadata */
export interface ParsedFilename {
provider: string;
endpoint: string;
timestamp: Date;
raw: string;
}
/**
* Parse error log filename to extract provider, endpoint, and timestamp
* Format: error-api-provider-{provider}-api-{endpoint}-{timestamp}-{id}.log
*/
export function parseFilename(name: string): ParsedFilename {
const result: ParsedFilename = {
provider: 'unknown',
endpoint: 'unknown',
timestamp: new Date(),
raw: name,
};
// Extract provider: error-api-provider-{PROVIDER}-api-...
const providerMatch = name.match(/error-api-provider-([^-]+)-/);
if (providerMatch) {
result.provider = providerMatch[1];
}
// Extract endpoint from after provider: ...-api-{ENDPOINT}-{timestamp}
// Example: error-api-provider-agy-api-event_logging-batch-2025-12-18T185041-...
const endpointMatch = name.match(/-api-([a-z_]+(?:-[a-z_]+)*)-\d{4}-\d{2}-\d{2}T/i);
if (endpointMatch) {
result.endpoint = endpointMatch[1].replace(/-/g, '/');
}
// Extract timestamp: 2025-12-18T185041
const tsMatch = name.match(/(\d{4}-\d{2}-\d{2}T\d{6})/);
if (tsMatch) {
const ts = tsMatch[1];
// Parse: 2025-12-18T185041 → 2025-12-18T18:50:41
const formatted = `${ts.slice(0, 10)}T${ts.slice(11, 13)}:${ts.slice(13, 15)}:${ts.slice(15, 17)}`;
result.timestamp = new Date(formatted);
}
return result;
}
/**
* Parse raw error log content into structured data
*/
export function parseErrorLog(content: string): ParsedErrorLog {
const result: ParsedErrorLog = {
version: '',
url: '',
method: '',
timestamp: '',
statusCode: 0,
statusText: '',
requestHeaders: {},
requestBody: '',
responseHeaders: {},
responseBody: '',
provider: '',
endpoint: '',
isClientError: false,
isServerError: false,
errorType: 'unknown',
};
// Split into sections
const sections = content.split(/^===\s*(.+?)\s*===$/m);
let currentSection = '';
for (let i = 0; i < sections.length; i++) {
const part = sections[i].trim();
if (part === 'REQUEST INFO') {
currentSection = 'request_info';
continue;
} else if (part === 'HEADERS') {
currentSection = 'headers';
continue;
} else if (part === 'REQUEST BODY') {
currentSection = 'request_body';
continue;
} else if (part === 'RESPONSE') {
currentSection = 'response';
continue;
}
// Parse section content
switch (currentSection) {
case 'request_info':
parseRequestInfo(part, result);
break;
case 'headers':
result.requestHeaders = parseHeaders(part);
break;
case 'request_body':
result.requestBody = part;
break;
case 'response':
parseResponse(part, result);
break;
}
}
// Compute derived fields
computeDerivedFields(result);
return result;
}
/** Parse REQUEST INFO section */
function parseRequestInfo(content: string, result: ParsedErrorLog): void {
const lines = content.split('\n');
for (const line of lines) {
const [key, ...valueParts] = line.split(':');
const value = valueParts.join(':').trim();
switch (key?.trim()?.toLowerCase()) {
case 'version':
result.version = value;
break;
case 'url':
result.url = value;
break;
case 'method':
result.method = value;
break;
case 'timestamp':
result.timestamp = value;
break;
}
}
}
/** Parse headers into key-value object */
function parseHeaders(content: string): Record<string, string> {
const headers: Record<string, string> = {};
const lines = content.split('\n');
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
if (key) headers[key] = value;
}
}
return headers;
}
/** Parse RESPONSE section */
function parseResponse(content: string, result: ParsedErrorLog): void {
const lines = content.split('\n');
let headersEnded = false;
const bodyLines: string[] = [];
for (const line of lines) {
// First line might be "Status: 404"
if (line.startsWith('Status:')) {
const statusStr = line.replace('Status:', '').trim();
const statusParts = statusStr.split(/\s+/);
result.statusCode = parseInt(statusParts[0], 10) || 0;
result.statusText = statusParts.slice(1).join(' ') || getStatusText(result.statusCode);
continue;
}
// Check for empty line (separates headers from body)
if (line.trim() === '' && !headersEnded) {
headersEnded = true;
continue;
}
// Parse response headers
if (!headersEnded) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
if (key) result.responseHeaders[key] = value;
}
} else {
bodyLines.push(line);
}
}
result.responseBody = bodyLines.join('\n').trim();
}
/** Compute derived fields from parsed data */
function computeDerivedFields(result: ParsedErrorLog): void {
// Extract provider from URL: /api/provider/{PROVIDER}/...
const providerMatch = result.url.match(/\/api\/provider\/([^/]+)/);
if (providerMatch) {
result.provider = providerMatch[1];
}
// Extract endpoint from URL
const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/api\/(.+)/);
if (endpointMatch) {
result.endpoint = endpointMatch[1];
}
// Status code classification
result.isClientError = result.statusCode >= 400 && result.statusCode < 500;
result.isServerError = result.statusCode >= 500;
// Error type classification
if (result.statusCode === 429) {
result.errorType = 'rate_limit';
} else if (result.statusCode === 401 || result.statusCode === 403) {
result.errorType = 'auth';
} else if (result.statusCode === 404) {
result.errorType = 'not_found';
} else if (result.statusCode >= 500) {
result.errorType = 'server';
} else if (result.statusCode === 408 || result.statusCode === 504) {
result.errorType = 'timeout';
}
}
/** Get status text for common codes */
function getStatusText(code: number): string {
const statusTexts: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
408: 'Request Timeout',
429: 'Too Many Requests',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
};
return statusTexts[code] || '';
}
/**
* Format Unix timestamp (seconds) to relative time string
*/
export function formatRelativeTime(modifiedSeconds: number): string {
const now = Date.now();
const modified = modifiedSeconds * 1000; // Convert to milliseconds
const diff = now - modified;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
// Format as date for older logs
const date = new Date(modified);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
/**
* Format bytes to human readable size
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Get status code badge color class
*/
export function getStatusColor(code: number): string {
if (code >= 500) return 'text-red-500';
if (code === 429) return 'text-orange-500';
if (code >= 400) return 'text-yellow-500';
return 'text-gray-500';
}
/**
* Get error type label
*/
export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string {
const labels: Record<string, string> = {
rate_limit: 'Rate Limited',
auth: 'Auth Error',
not_found: 'Not Found',
server: 'Server Error',
timeout: 'Timeout',
unknown: 'Error',
};
return labels[type] || 'Error';
}