mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 10:15:40 +00:00
Merge pull request #133 from kaitranntt/kai/feat/error-log-viewer
feat(dashboard): add error log viewer for CLIProxy diagnostics
This commit is contained in:
9 files changed
+599
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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,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 (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<Terminal className="w-8 h-8 mx-auto opacity-40" />
|
||||
<p className="text-xs">Select a log to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Demo mode
|
||||
if (demo) {
|
||||
const { endpoint } = parseErrorLogName(name);
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="px-3 py-2 border-b border-border bg-muted/30 flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium truncate">{endpoint}</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<pre className="p-3 text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{DEMO_LOG_CONTENT}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 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" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error or no content
|
||||
if (error || !content) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<p className="text-xs">Failed to load log content</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show content
|
||||
const { endpoint } = parseErrorLogName(name);
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="px-3 py-2 border-b border-border bg-muted/30 flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium truncate">{endpoint}</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<pre className="p-3 text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full px-3 py-2.5 flex items-start gap-2 text-left transition-colors',
|
||||
'hover:bg-muted/40 border-l-2',
|
||||
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
|
||||
)}
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: STATUS_COLORS.failed }} />
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="text-[11px] font-medium truncate" title={endpoint}>
|
||||
{endpoint}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[9px] text-muted-foreground">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{formatRelativeTime(modified)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<FileText className="w-2.5 h-2.5" />
|
||||
{formatSize(size)}
|
||||
</span>
|
||||
</div>
|
||||
{timestamp && (
|
||||
<div className="text-[9px] text-muted-foreground/70 truncate">{timestamp}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
if (!logs || logs.length === 0) return null;
|
||||
}
|
||||
|
||||
const errorCount = displayLogs?.length ?? 0;
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
<AlertTriangle className="w-4 h-4" style={{ color: STATUS_COLORS.degraded }} />
|
||||
<span className="text-xs font-semibold tracking-tight">Error Logs</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{demo && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium bg-violet-500/20 text-violet-400 border border-violet-500/30">
|
||||
<FlaskConical className="w-2.5 h-2.5" />
|
||||
DEMO
|
||||
</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>
|
||||
|
||||
{/* Split View: List (left) + Content (right) */}
|
||||
<div className="flex h-[280px]">
|
||||
{/* Left Panel: Log List */}
|
||||
<div className="w-[240px] shrink-0 border-r border-border">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="divide-y divide-border/50">
|
||||
{displayLogs?.slice(0, 10).map((log) => (
|
||||
<ErrorLogItem
|
||||
key={log.name}
|
||||
name={log.name}
|
||||
size={log.size}
|
||||
modified={log.modified}
|
||||
isSelected={effectiveSelection === log.name}
|
||||
onClick={() => setSelectedLog(log.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{(displayLogs?.length ?? 0) > 10 && (
|
||||
<div className="px-3 py-2 text-center text-[9px] text-muted-foreground border-t border-border/50">
|
||||
Showing 10 of {displayLogs?.length} logs
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Log Content */}
|
||||
<LogContentPanel name={effectiveSelection} demo={demo} />
|
||||
</div>
|
||||
|
||||
{/* Footer error */}
|
||||
{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