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
This commit is contained in:
kaitranntt
2025-12-18 02:15:35 -05:00
parent 1a140e4a32
commit 5b3d56548a
6 changed files with 451 additions and 0 deletions
+84
View File
@@ -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<CliproxyErrorLog[] | null> {
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<string | null> {
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)
+73
View File
@@ -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<void> => {
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<void> => {
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
// ============================================
+212
View File
@@ -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 (
<div className="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" />
</div>
);
}
if (error || !content) {
return (
<div className="p-4 text-xs text-muted-foreground">Failed to load error log content</div>
);
}
return (
<ScrollArea className="h-[200px] rounded-md">
<pre className="p-4 text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
{content}
</pre>
</ScrollArea>
);
}
export function ErrorLogsMonitor() {
const { data: status } = useCliproxyStatus();
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running);
const [expandedLog, setExpandedLog] = useState<string | null>(null);
// Don't show if proxy not running or loading
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>
);
}
// Don't show if no errors (good state)
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">
{/* Header with warning styling */}
<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">
<div className="relative flex items-center justify-center w-5 h-5">
<AlertTriangle className="w-4 h-4" style={{ color: STATUS_COLORS.degraded }} />
</div>
<span className="text-xs font-semibold tracking-tight text-foreground">Error Logs</span>
<span className="text-[10px] text-muted-foreground">
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<FileWarning className="w-3 h-3" />
<span>CLIProxy Diagnostics</span>
</div>
</div>
{/* Error logs list */}
<ScrollArea className="max-h-[300px]">
<div className="divide-y divide-border">
{logs.slice(0, 10).map((log) => {
const isExpanded = expandedLog === log.name;
const { endpoint, timestamp } = parseErrorLogName(log.name);
return (
<div key={log.name} className="bg-background/50">
<button
onClick={() => setExpandedLog(isExpanded ? null : log.name)}
className={cn(
'w-full px-4 py-3 flex items-center gap-3 text-left transition-colors',
'hover:bg-muted/30',
isExpanded && 'bg-muted/20'
)}
>
{/* Expand icon */}
<div className="shrink-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</div>
{/* Error indicator */}
<XCircle className="w-4 h-4 shrink-0" style={{ color: STATUS_COLORS.failed }} />
{/* Endpoint */}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate" title={endpoint}>
{endpoint}
</div>
{timestamp && (
<div className="text-[10px] text-muted-foreground">{timestamp}</div>
)}
</div>
{/* Metadata */}
<div className="flex items-center gap-3 shrink-0 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="w-3 h-3" />
<span>{formatSize(log.size)}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatRelativeTime(log.modified)}</span>
</div>
</div>
</button>
{/* Expandable content */}
{isExpanded && (
<div className="border-t border-border bg-muted/10">
<ErrorLogContent name={log.name} />
</div>
)}
</div>
);
})}
</div>
{/* Show more indicator */}
{logs.length > 10 && (
<div className="px-4 py-2 text-center text-[10px] text-muted-foreground border-t border-border">
Showing 10 of {logs.length} error logs
</div>
)}
</ScrollArea>
{/* Footer hint */}
{error && (
<div className="px-4 py-2 border-t border-border text-[10px] text-destructive">
{error.message}
</div>
)}
</div>
);
}
+57
View File
@@ -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<CliproxyErrorLog[]> {
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<string> {
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
});
}
+21
View File
@@ -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<string> => {
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'),
+4
View File
@@ -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 */}
<AuthMonitor />
{/* Error Logs Monitor - shows only when there are errors */}
<ErrorLogsMonitor />
</div>
);
}