From 5b3d56548a8dfb2e6bb22e14b13f0fb038f2d1fb Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 02:15:35 -0500 Subject: [PATCH] feat(dashboard): add error log viewer for CLIProxy diagnostics Add ErrorLogsMonitor component to Home page that displays CLIProxyAPI error logs when requests fail. Users can now diagnose why success rates drop by viewing detailed error log contents. Backend: - Add fetchCliproxyErrorLogs/fetchCliproxyErrorLogContent in stats-fetcher - Add GET /api/cliproxy/error-logs and /api/cliproxy/error-logs/:name routes - Include path traversal protection for filename validation Frontend: - Add CliproxyErrorLog type and errorLogs API methods - Add useCliproxyErrorLogs/useCliproxyErrorLogContent hooks - Create ErrorLogsMonitor component with expandable log viewer - Integrate into Home page below AuthMonitor Closes #132 --- src/cliproxy/stats-fetcher.ts | 84 +++++++++ src/web-server/routes.ts | 73 ++++++++ ui/src/components/error-logs-monitor.tsx | 212 +++++++++++++++++++++++ ui/src/hooks/use-cliproxy-stats.ts | 57 ++++++ ui/src/lib/api-client.ts | 21 +++ ui/src/pages/home.tsx | 4 + 6 files changed, 451 insertions(+) create mode 100644 ui/src/components/error-logs-monitor.tsx diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index aaf00058..cafbc526 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -271,6 +271,90 @@ export async function fetchCliproxyModels( } } +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + /** Filename (e.g., "error-v1-chat-completions-2025-01-15T10-30-00.log") */ + name: string; + /** File size in bytes */ + size: number; + /** Last modified timestamp (Unix seconds) */ + modified: number; +} + +/** Response from /v0/management/request-error-logs endpoint */ +interface ErrorLogsApiResponse { + files: CliproxyErrorLog[]; +} + +/** + * Fetch error log file list from CLIProxyAPI management API + * @param port CLIProxyAPI port (default: 8317) + * @returns Array of error log metadata or null if unavailable + */ +export async function fetchCliproxyErrorLogs( + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, { + signal: controller.signal, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as ErrorLogsApiResponse; + return data.files ?? []; + } catch { + return null; + } +} + +/** + * Fetch error log file content from CLIProxyAPI management API + * @param name Error log filename + * @param port CLIProxyAPI port (default: 8317) + * @returns Log file content as string or null if unavailable + */ +export async function fetchCliproxyErrorLogContent( + name: string, + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch( + `http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`, + { + signal: controller.signal, + headers: { + Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, + }, + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + return await response.text(); + } catch { + return null; + } +} + /** * Check if CLIProxyAPI is running and responsive * @param port CLIProxyAPI port (default: 8317) diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 9a4486fd..7e9a44a5 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -21,6 +21,8 @@ import { fetchCliproxyStats, fetchCliproxyModels, isCliproxyRunning, + fetchCliproxyErrorLogs, + fetchCliproxyErrorLogContent, } from '../cliproxy/stats-fetcher'; import { listOpenAICompatProviders, @@ -1374,6 +1376,77 @@ apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise< } }); +// ==================== Error Logs ==================== + +/** + * GET /api/cliproxy/error-logs - Get list of error log files + * Returns: { files: CliproxyErrorLog[] } or error if proxy not running + */ +apiRoutes.get('/cliproxy/error-logs', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session to view error logs', + }); + return; + } + + const files = await fetchCliproxyErrorLogs(); + if (files === null) { + res.status(503).json({ + error: 'Error logs unavailable', + message: 'CLIProxyAPI is running but error logs endpoint not responding', + }); + return; + } + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/error-logs/:name - Get content of a specific error log + * Returns: plain text log content + */ +apiRoutes.get('/cliproxy/error-logs/:name', async (req: Request, res: Response): Promise => { + const { name } = req.params; + + // Validate filename format and prevent path traversal + if ( + !name || + !name.startsWith('error-') || + !name.endsWith('.log') || + name.includes('..') || + name.includes('/') || + name.includes('\\') + ) { + res.status(400).json({ error: 'Invalid error log filename' }); + return; + } + + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ error: 'CLIProxyAPI not running' }); + return; + } + + const content = await fetchCliproxyErrorLogContent(name); + if (content === null) { + res.status(404).json({ error: 'Error log not found' }); + return; + } + + res.type('text/plain').send(content); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + // ============================================ // OpenAI Compatibility Layer Routes // ============================================ diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx new file mode 100644 index 00000000..4aeaf45a --- /dev/null +++ b/ui/src/components/error-logs-monitor.tsx @@ -0,0 +1,212 @@ +/** + * Error Logs Monitor Component + * + * Displays CLIProxyAPI error logs with expandable details. + * Designed to complement the AuthMonitor on the Home page. + */ + +import { useState } from 'react'; +import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; +import { cn, STATUS_COLORS } from '@/lib/utils'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + FileWarning, + Clock, + FileText, + XCircle, +} from 'lucide-react'; + +/** 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 } { + // Format: error-v1-chat-completions-2025-01-15T10-30-00.log + const match = name.match(/^error-(.+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.log$/); + if (match) { + const endpoint = match[1].replace(/-/g, '/'); + const timestamp = match[2].replace(/T/, ' ').replace(/-/g, ':'); + return { endpoint: `/${endpoint}`, timestamp }; + } + return { endpoint: name, timestamp: '' }; +} + +/** Error log content viewer with syntax highlighting */ +function ErrorLogContent({ name }: { name: string }) { + const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (error || !content) { + return ( +
Failed to load error log content
+ ); + } + + return ( + +
+        {content}
+      
+
+ ); +} + +export function ErrorLogsMonitor() { + const { data: status } = useCliproxyStatus(); + const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running); + const [expandedLog, setExpandedLog] = useState(null); + + // Don't show if proxy not running or loading + if (!status?.running) { + return null; + } + + if (isLoading) { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + + // Don't show if no errors (good state) + if (!logs || logs.length === 0) { + return null; + } + + const errorCount = logs.length; + + return ( +
+ {/* Header with warning styling */} +
+
+
+ +
+ Error Logs + + {errorCount} failed request{errorCount !== 1 ? 's' : ''} + +
+
+ + CLIProxy Diagnostics +
+
+ + {/* Error logs list */} + +
+ {logs.slice(0, 10).map((log) => { + const isExpanded = expandedLog === log.name; + const { endpoint, timestamp } = parseErrorLogName(log.name); + + return ( +
+ + + {/* Expandable content */} + {isExpanded && ( +
+ +
+ )} +
+ ); + })} +
+ + {/* Show more indicator */} + {logs.length > 10 && ( +
+ Showing 10 of {logs.length} error logs +
+ )} +
+ + {/* Footer hint */} + {error && ( +
+ {error.message} +
+ )} +
+ ); +} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 3d27c7a9..3bea8b29 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -130,3 +130,60 @@ export function useCliproxyModels(enabled = true) { retry: 1, }); } + +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + name: string; + size: number; + modified: number; +} + +/** + * Fetch CLIProxy error logs from API + */ +async function fetchCliproxyErrorLogs(): Promise { + const response = await fetch('/api/cliproxy/error-logs'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to fetch error logs'); + } + const data = await response.json(); + return data.files ?? []; +} + +/** + * Fetch specific error log content + */ +async function fetchCliproxyErrorLogContent(name: string): Promise { + const response = await fetch(`/api/cliproxy/error-logs/${encodeURIComponent(name)}`); + if (!response.ok) { + throw new Error('Failed to fetch error log content'); + } + return response.text(); +} + +/** + * Hook to get CLIProxy error logs list + */ +export function useCliproxyErrorLogs(enabled = true) { + return useQuery({ + queryKey: ['cliproxy-error-logs'], + queryFn: fetchCliproxyErrorLogs, + enabled, + refetchInterval: 30000, // Refresh every 30 seconds + retry: 1, + staleTime: 10000, + }); +} + +/** + * Hook to get specific error log content + */ +export function useCliproxyErrorLogContent(name: string | null) { + return useQuery({ + queryKey: ['cliproxy-error-log-content', name], + queryFn: () => (name ? fetchCliproxyErrorLogContent(name) : Promise.resolve('')), + enabled: !!name, + staleTime: 60000, // Cache log content for 1 minute + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 7ca955b4..9732272b 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -163,6 +163,16 @@ export interface ProxyProcessStatus { startedAt?: string; } +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + /** Filename (e.g., "error-v1-chat-completions-2025-01-15T10-30-00.log") */ + name: string; + /** File size in bytes */ + size: number; + /** Last modified timestamp (Unix seconds) */ + modified: number; +} + /** Result from starting proxy service */ export interface ProxyStartResult { started: boolean; @@ -266,6 +276,17 @@ export const api = { body: JSON.stringify({ nickname }), }), }, + // Error logs + errorLogs: { + /** List error log files */ + list: () => request<{ files: CliproxyErrorLog[] }>('/cliproxy/error-logs'), + /** Get content of a specific error log */ + getContent: async (name: string): Promise => { + const res = await fetch(`${BASE_URL}/cliproxy/error-logs/${encodeURIComponent(name)}`); + if (!res.ok) throw new Error('Failed to load error log'); + return res.text(); + }, + }, }, accounts: { list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'), diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index ccc5bf92..e864b87a 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom'; import { HeroSection } from '@/components/hero-section'; import { AuthMonitor } from '@/components/auth-monitor'; +import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; @@ -175,6 +176,9 @@ export function HomePage() { {/* Auth Monitor */} + + {/* Error Logs Monitor - shows only when there are errors */} + ); }