mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 18:16:08 +00:00
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:
@@ -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 */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -136,6 +136,8 @@ export interface CliproxyErrorLog {
|
||||
name: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
/** Absolute path to the log file (injected by backend) */
|
||||
absolutePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user