mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user