diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index bef6a2fa..1bad1d16 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -24,6 +24,7 @@ import { getProviderConfig, ensureProviderSettings, CLIPROXY_DEFAULT_PORT, + getCliproxyWritablePath, } from './config-generator'; import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; @@ -369,6 +370,10 @@ export async function execClaudeWithCLIProxy( proxy = spawn(binaryPath, proxyArgs, { stdio: ['ignore', 'ignore', 'ignore'], detached: true, // Persist after parent terminal closes + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + }, }); // Unref so parent process can exit independently diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 3383d313..7f572dba 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -30,6 +30,15 @@ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; /** Simple secret key for Control Panel login (user-facing) */ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; +/** + * Get CLIProxy writable directory for logs and runtime files. + * This directory is set as WRITABLE_PATH env var when spawning CLIProxy. + * Logs will be stored in ~/.ccs/cliproxy/logs/ + */ +export function getCliproxyWritablePath(): string { + return path.join(getCcsDir(), 'cliproxy'); +} + /** * Config version - bump when config format changes to trigger regeneration * v1: Initial config (port, auth-dir, api-keys only) diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index a515dcc0..f6977a07 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -19,6 +19,7 @@ import { regenerateConfig, configNeedsRegeneration, CLIPROXY_DEFAULT_PORT, + getCliproxyWritablePath, } from './config-generator'; import { isCliproxyRunning } from './stats-fetcher'; @@ -171,6 +172,10 @@ export async function ensureCliproxyService( proxyProcess = spawn(binaryPath, proxyArgs, { stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], detached: true, // Allow process to run independently + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + }, }); // Forward output in verbose mode 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 e689ed5d..f6b4f4b7 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -22,6 +22,8 @@ import { fetchCliproxyStats, fetchCliproxyModels, isCliproxyRunning, + fetchCliproxyErrorLogs, + fetchCliproxyErrorLogContent, } from '../cliproxy/stats-fetcher'; import { listOpenAICompatProviders, @@ -1418,6 +1420,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..b399a67b --- /dev/null +++ b/ui/src/components/error-logs-monitor.tsx @@ -0,0 +1,341 @@ +/** + * Error Logs Monitor Component + * + * Displays CLIProxyAPI error logs with master-detail split view. + * Log list on left, content panel on right for better readability. + */ + +import { useState, useMemo } 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, + FileWarning, + Clock, + FileText, + XCircle, + FlaskConical, + Terminal, +} from 'lucide-react'; + +/** 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, + }, +]; + +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 +} + +-------------------------------------------------------------------------------- +RESPONSE +-------------------------------------------------------------------------------- +{ + "error": { + "message": "Rate limit exceeded. Please retry after 60 seconds.", + "type": "rate_limit_error", + "code": "rate_limit_exceeded" + } +} + +-------------------------------------------------------------------------------- +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 }; + } + return { endpoint: name, timestamp: '' }; +} + +/** Log content panel component */ +function LogContentPanel({ name, demo = false }: { name: string | null; demo?: boolean }) { + const { data: content, isLoading, error } = useCliproxyErrorLogContent(demo ? null : name); + + // No log selected + if (!name) { + return ( +
+
+ +

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) { + return ( +
+

Failed to load log content

+
+ ); + } + + // Show content + const { endpoint } = parseErrorLogName(name); + return ( +
+
+ + {endpoint} +
+ +
+          {content}
+        
+
+
+ ); +} + +/** Error log item in the list */ +interface ErrorLogItemProps { + name: string; + size: number; + modified: number; + isSelected: boolean; + onClick: () => void; +} + +function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) { + const { endpoint, timestamp } = parseErrorLogName(name); + + return ( + + ); +} + +export function ErrorLogsMonitor({ demo = false }: { demo?: boolean } = {}) { + 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; + + // Compute default selection (first log name or null) + const defaultLogName = useMemo(() => displayLogs?.[0]?.name ?? null, [displayLogs]); + + // Use controlled selection that defaults to first log + const [selectedLog, setSelectedLog] = useState(null); + + // 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; + } + + const errorCount = displayLogs?.length ?? 0; + + return ( +
+ {/* Header */} +
+
+ + 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 +
+ )} +
+
+ + {/* Right Panel: Log Content */} + +
+ + {/* Footer error */} + {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 */} + ); }