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 */} + ); }