diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts
index cafbc526..e7506a74 100644
--- a/src/cliproxy/stats-fetcher.ts
+++ b/src/cliproxy/stats-fetcher.ts
@@ -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 */
diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts
index f6b4f4b7..7ccd973d 100644
--- a/src/web-server/routes.ts
+++ b/src/web-server/routes.ts
@@ -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 });
}
diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx
index b399a67b..eb704397 100644
--- a/ui/src/components/error-logs-monitor.tsx
+++ b/ui/src/components/error-logs-monitor.tsx
@@ -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 (
+
+ );
}
---------------------------------------------------------------------------------
-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 (
+
+ {code}
+
+ );
+}
+
+/** Overview tab content */
+function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
+ return (
+
+ {/* Status row */}
+
+
+ {parsed.statusText}
+
+ {getErrorTypeLabel(parsed.errorType)}
+
+
+
+ {/* Key metrics grid */}
+
+
+
Method
+
{parsed.method || 'N/A'}
+
+
+
Provider
+
{parsed.provider || 'N/A'}
+
+
+
Version
+
{parsed.version || 'N/A'}
+
+
+
Endpoint
+
+ {parsed.endpoint || 'N/A'}
+
+
+
+
+ {/* URL */}
+
+
URL
+
+ {parsed.url || 'N/A'}
+
+
+
+ {/* Timestamp */}
+
+
Timestamp
+
{parsed.timestamp || 'N/A'}
+
+
+ {/* Suggestion based on error type */}
+ {parsed.errorType !== 'unknown' && (
+
+
+
+ {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.'}
+
+
+ )}
+
+ );
+}
+
+/** Headers tab content */
+function HeadersTab({ headers }: { headers: Record }) {
+ const entries = Object.entries(headers);
+ if (entries.length === 0) {
+ return No headers available
;
}
+
+ return (
+
+
+ {entries.map(([key, value]) => (
+
+ {key}:
+ {value}
+
+ ))}
+
+
+ );
}
---------------------------------------------------------------------------------
-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 No {label.toLowerCase()} body
;
}
- 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 (
+
+
+ {formatted}
+
+
+ );
}
-/** 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 (
+
+
+ {content}
+
+
+ );
+}
+
+/** Log content panel with tabs */
+function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) {
+ const [activeTab, setActiveTab] = useState('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 (
-
-
-
Select a log to view details
+
+
+
Select a log to view details
);
}
- // Demo mode
- if (demo) {
- const { endpoint } = parseErrorLogName(name);
- return (
-
-
-
- {endpoint}
-
-
-
- {DEMO_LOG_CONTENT}
-
-
-
- );
- }
-
// Loading state
if (isLoading) {
return (
-
-
-
-
-
+
+
+
+
+
);
}
// Error or no content
- if (error || !content) {
+ if (error || !content || !parsed) {
return (
-
Failed to load log content
+
Failed to load log content
);
}
- // Show content
- const { endpoint } = parseErrorLogName(name);
return (
-
-
-
-
{endpoint}
+
+ {/* Header with status */}
+
+
+
+
+ {parsed.provider}/{parsed.endpoint || 'unknown'}
+
+ {/* Copy Absolute Path Button */}
+ {name && (
+
+ )}
+
+
+ {/* Copy Raw Content Button */}
+ {content && (
+
+ )}
+
+ {parsed.method}
+
+
+
+
+ {/* Tabs */}
+
+ setActiveTab('overview')}
+ icon={Info}
+ >
+ Overview
+
+ setActiveTab('headers')}
+ icon={Code}
+ >
+ Headers
+
+ setActiveTab('request')}
+ icon={ArrowUpRight}
+ >
+ Request
+
+ setActiveTab('response')}
+ icon={ArrowDownLeft}
+ >
+ Response
+
+ setActiveTab('raw')} icon={FileText}>
+ Raw
+
+
+
+ {/* Tab content */}
+
+ {activeTab === 'overview' && }
+ {activeTab === 'headers' && }
+ {activeTab === 'request' && }
+ {activeTab === 'response' && }
+ {activeTab === 'raw' && }
-
-
- {content}
-
-
);
}
@@ -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 (