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 ( ); } -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(null); + const scrollIntervalRef = useRef(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(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 ( -
-
- - -
-
- {[1, 2, 3].map((i) => ( - - ))} -
-
- ); - } - 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 ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + if (!logs || logs.length === 0) return null; + + const errorCount = logs.length; return ( -
+
{/* Header */} -
-
- - Error Logs - +
+
+ + Error Logs + {errorCount} failed request{errorCount !== 1 ? 's' : ''} - {demo && ( - - - DEMO - - )}
-
- +
+ CLIProxy Diagnostics
- {/* Split View: List (left) + Content (right) */} -
- {/* Left Panel: Log List */} -
- -
- {displayLogs?.slice(0, 10).map((log) => ( - setSelectedLog(log.name)} - /> - ))} -
- {(displayLogs?.length ?? 0) > 10 && ( -
- Showing 10 of {displayLogs?.length} logs + {/* Resizable Panel Layout */} +
+ + {/* Left Panel: Log List */} + + +
+ {logs.slice(0, 50).map((log) => ( + setSelectedLog(log.name)} + /> + ))}
- )} -
-
+ {logs.length > 50 && ( +
+ Showing 50 of {logs.length} logs +
+ )} + + - {/* Right Panel: Log Content */} - + {/* Resize Handle */} + +
+
+ +
+ + + {/* Right Panel: Log Content */} + + + +
- {/* Footer error */} - {error && ( -
+ {/* Use standard footer if error, otherwise show resize handle */} + {error ? ( +
{error.message}
+ ) : ( +
+ +
)}
); diff --git a/ui/src/components/ui/copy-button.tsx b/ui/src/components/ui/copy-button.tsx index 5453f328..3f296151 100644 --- a/ui/src/components/ui/copy-button.tsx +++ b/ui/src/components/ui/copy-button.tsx @@ -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['variant']; + size?: VariantProps['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({ diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 3bea8b29..2e825e01 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -136,6 +136,8 @@ export interface CliproxyErrorLog { name: string; size: number; modified: number; + /** Absolute path to the log file (injected by backend) */ + absolutePath?: string; } /** diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts new file mode 100644 index 00000000..3afa030c --- /dev/null +++ b/ui/src/lib/error-log-parser.ts @@ -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; + requestBody: string; + responseHeaders: Record; + 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 { + const headers: Record = {}; + 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 = { + 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 = { + rate_limit: 'Rate Limited', + auth: 'Auth Error', + not_found: 'Not Found', + server: 'Server Error', + timeout: 'Timeout', + unknown: 'Error', + }; + return labels[type] || 'Error'; +}