mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
refactor(ui): split error-logs-monitor into monitoring/error-logs/ directory
- extract 617-line component into 6 focused modules - move auth-monitor, proxy-status-widget to monitoring/ - add barrel export in monitoring/index.ts
This commit is contained in:
1 parent
1b1015cf50
commit
946030c836
10 files changed
+1336
-613
No files matched your search
@@ -1,617 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Error Logs Monitor Component
|
* Error Logs Monitor - Re-export from modular structure
|
||||||
*
|
* @deprecated Import from '@/components/monitoring/error-logs' directly
|
||||||
* Displays CLIProxyAPI error logs with master-detail split view.
|
|
||||||
* ETL: Parses raw logs into structured data for rich display.
|
|
||||||
* - Left panel: Log list with status code, provider, endpoint, relative time
|
|
||||||
* - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
export {
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
ErrorLogsMonitor,
|
||||||
import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
|
ErrorLogItem,
|
||||||
import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
LogContentPanel,
|
||||||
import { cn } from '@/lib/utils';
|
TabButton,
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
StatusBadge,
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
OverviewTab,
|
||||||
import { ProviderIcon } from '@/components/provider-icon';
|
HeadersTab,
|
||||||
import { CopyButton } from '@/components/ui/copy-button';
|
BodyTab,
|
||||||
import {
|
RawTab,
|
||||||
AlertTriangle,
|
} from './monitoring/error-logs';
|
||||||
FileWarning,
|
|
||||||
Clock,
|
|
||||||
FileText,
|
|
||||||
Terminal,
|
|
||||||
Info,
|
|
||||||
Code,
|
|
||||||
ArrowUpRight,
|
|
||||||
ArrowDownLeft,
|
|
||||||
GripVertical,
|
|
||||||
GripHorizontal,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
parseErrorLog,
|
|
||||||
parseFilename,
|
|
||||||
formatRelativeTime,
|
|
||||||
formatBytes,
|
|
||||||
getStatusColor,
|
|
||||||
getErrorTypeLabel,
|
|
||||||
type ParsedErrorLog,
|
|
||||||
} from '@/lib/error-log-parser';
|
|
||||||
|
|
||||||
type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw';
|
export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './monitoring/error-logs';
|
||||||
|
|
||||||
/** Tab button component */
|
|
||||||
function TabButton({
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
children,
|
|
||||||
icon: Icon,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
icon?: React.ComponentType<{ className?: string }>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'px-2.5 py-1.5 text-xs font-medium rounded transition-colors flex items-center gap-1.5',
|
|
||||||
active
|
|
||||||
? 'bg-primary/15 text-primary'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="w-3.5 h-3.5" />}
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Status badge component */
|
|
||||||
function StatusBadge({ code }: { code: number }) {
|
|
||||||
const colorClass = getStatusColor(code);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center justify-center min-w-[36px] px-2 py-0.5 rounded text-xs font-bold',
|
|
||||||
'bg-current/10 border border-current/20',
|
|
||||||
colorClass
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Overview tab content */
|
|
||||||
function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{/* Status row */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<StatusBadge code={parsed.statusCode} />
|
|
||||||
<span className="text-sm font-medium">{parsed.statusText}</span>
|
|
||||||
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
|
|
||||||
{getErrorTypeLabel(parsed.errorType)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key metrics grid */}
|
|
||||||
<div className="grid grid-cols-4 gap-3 text-xs">
|
|
||||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
|
||||||
<div className="text-muted-foreground mb-1">Method</div>
|
|
||||||
<div className="font-medium">{parsed.method || 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
|
||||||
<div className="text-muted-foreground mb-1">Provider</div>
|
|
||||||
<div className="font-medium">{parsed.provider || 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
|
||||||
<div className="text-muted-foreground mb-1">Version</div>
|
|
||||||
<div className="font-medium">{parsed.version || 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
|
||||||
<div className="text-muted-foreground mb-1">Endpoint</div>
|
|
||||||
<div className="font-medium truncate" title={parsed.endpoint}>
|
|
||||||
{parsed.endpoint || 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* URL */}
|
|
||||||
<div className="text-xs">
|
|
||||||
<div className="text-muted-foreground mb-1.5">URL</div>
|
|
||||||
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
|
|
||||||
{parsed.url || 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
<div className="text-xs">
|
|
||||||
<div className="text-muted-foreground mb-1.5">Timestamp</div>
|
|
||||||
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Suggestion based on error type */}
|
|
||||||
{parsed.errorType !== 'unknown' && (
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded bg-blue-500/10 border border-blue-500/20 text-xs">
|
|
||||||
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
|
|
||||||
<div className="text-blue-500/90 leading-relaxed">
|
|
||||||
{parsed.errorType === 'rate_limit' &&
|
|
||||||
'Rate limited. Consider using multiple accounts or reducing request frequency.'}
|
|
||||||
{parsed.errorType === 'auth' &&
|
|
||||||
'Authentication failed. Check credentials or re-authenticate with the provider.'}
|
|
||||||
{parsed.errorType === 'not_found' &&
|
|
||||||
'Endpoint not found. This endpoint may not exist on this provider.'}
|
|
||||||
{parsed.errorType === 'server' &&
|
|
||||||
'Server error from upstream. Retry or check provider status.'}
|
|
||||||
{parsed.errorType === 'timeout' &&
|
|
||||||
'Request timed out. Check network or increase timeout settings.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Headers tab content */
|
|
||||||
function HeadersTab({ headers }: { headers: Record<string, string> }) {
|
|
||||||
const entries = Object.entries(headers);
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return <div className="p-4 text-xs text-muted-foreground">No headers available</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="p-4 space-y-1">
|
|
||||||
{entries.map(([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex gap-3 text-xs font-mono py-1.5 border-b border-border/30 last:border-0"
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground shrink-0 min-w-[140px]">{key}:</span>
|
|
||||||
<span className="break-all">{value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** JSON/Body tab content */
|
|
||||||
function BodyTab({ content, label }: { content: string; label: string }) {
|
|
||||||
if (!content || content.trim() === '') {
|
|
||||||
return <div className="p-4 text-xs text-muted-foreground">No {label.toLowerCase()} body</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to format as JSON
|
|
||||||
let formatted = content;
|
|
||||||
let isJson = false;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(content);
|
|
||||||
formatted = JSON.stringify(parsed, null, 2);
|
|
||||||
isJson = true;
|
|
||||||
} catch {
|
|
||||||
// Not JSON, use as-is
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<pre
|
|
||||||
className={cn(
|
|
||||||
'p-4 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed',
|
|
||||||
isJson
|
|
||||||
? 'text-emerald-700 dark:text-green-400'
|
|
||||||
: 'text-zinc-700 dark:text-muted-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatted}
|
|
||||||
</pre>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raw tab content */
|
|
||||||
function RawTab({ content }: { content: string }) {
|
|
||||||
return (
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<pre className="p-4 text-xs font-mono text-zinc-700 dark:text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
|
|
||||||
{content}
|
|
||||||
</pre>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Log content panel with tabs */
|
|
||||||
function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
|
||||||
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
|
|
||||||
|
|
||||||
// Parse log content
|
|
||||||
const parsed = useMemo(() => {
|
|
||||||
if (!content) return null;
|
|
||||||
return parseErrorLog(content);
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
// No log selected
|
|
||||||
if (!name) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
||||||
<div className="text-center space-y-3">
|
|
||||||
<Terminal className="w-10 h-10 mx-auto opacity-40" />
|
|
||||||
<p className="text-sm">Select a log to view details</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 p-6 space-y-3">
|
|
||||||
<Skeleton className="h-5 w-full" />
|
|
||||||
<Skeleton className="h-5 w-3/4" />
|
|
||||||
<Skeleton className="h-5 w-5/6" />
|
|
||||||
<Skeleton className="h-5 w-2/3" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error or no content
|
|
||||||
if (error || !content || !parsed) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
||||||
<p className="text-sm">Failed to load log content</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 h-full">
|
|
||||||
{/* Header with status */}
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center justify-between gap-3 shrink-0">
|
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
||||||
<StatusBadge code={parsed.statusCode} />
|
|
||||||
<span className="text-xs font-semibold truncate text-foreground">
|
|
||||||
{parsed.provider}/{parsed.endpoint || 'unknown'}
|
|
||||||
</span>
|
|
||||||
{/* Copy Absolute Path Button */}
|
|
||||||
{name && (
|
|
||||||
<CopyButton
|
|
||||||
value={absolutePath || name}
|
|
||||||
label="Copy absolute path"
|
|
||||||
size="icon-sm"
|
|
||||||
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{/* Copy Raw Content Button */}
|
|
||||||
{content && (
|
|
||||||
<CopyButton
|
|
||||||
value={content}
|
|
||||||
label="Copy raw log content"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded border border-border/50">
|
|
||||||
{parsed.method}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="px-3 py-2 border-b border-border flex items-center gap-1 bg-muted/10 shrink-0 overflow-x-auto">
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'overview'}
|
|
||||||
onClick={() => setActiveTab('overview')}
|
|
||||||
icon={Info}
|
|
||||||
>
|
|
||||||
Overview
|
|
||||||
</TabButton>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'headers'}
|
|
||||||
onClick={() => setActiveTab('headers')}
|
|
||||||
icon={Code}
|
|
||||||
>
|
|
||||||
Headers
|
|
||||||
</TabButton>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'request'}
|
|
||||||
onClick={() => setActiveTab('request')}
|
|
||||||
icon={ArrowUpRight}
|
|
||||||
>
|
|
||||||
Request
|
|
||||||
</TabButton>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'response'}
|
|
||||||
onClick={() => setActiveTab('response')}
|
|
||||||
icon={ArrowDownLeft}
|
|
||||||
>
|
|
||||||
Response
|
|
||||||
</TabButton>
|
|
||||||
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
|
|
||||||
Raw
|
|
||||||
</TabButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<div className="flex-1 overflow-hidden bg-card/30">
|
|
||||||
{activeTab === 'overview' && <OverviewTab parsed={parsed} />}
|
|
||||||
{activeTab === 'headers' && <HeadersTab headers={parsed.requestHeaders} />}
|
|
||||||
{activeTab === 'request' && <BodyTab content={parsed.requestBody} label="Request" />}
|
|
||||||
{activeTab === 'response' && <BodyTab content={parsed.responseBody} label="Response" />}
|
|
||||||
{activeTab === 'raw' && <RawTab content={content} />}
|
|
||||||
</div>
|
|
||||||
</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 parsed = useMemo(() => parseFilename(name), [name]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'w-full px-3 py-2.5 flex items-start gap-3 text-left transition-colors',
|
|
||||||
'hover:bg-muted/40 border-l-[3px]',
|
|
||||||
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Provider Icon */}
|
|
||||||
<ProviderIcon
|
|
||||||
provider={parsed.provider}
|
|
||||||
size={24}
|
|
||||||
withBackground={true}
|
|
||||||
className="shrink-0 mt-0.5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
|
||||||
{/* Provider / Endpoint */}
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-semibold text-foreground truncate">
|
|
||||||
{parsed.provider}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-[9px] px-1 rounded border',
|
|
||||||
isSelected
|
|
||||||
? 'bg-amber-500/10 text-amber-600 border-amber-500/20'
|
|
||||||
: 'bg-muted border-border text-muted-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
LOG
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="text-[11px] text-muted-foreground truncate font-medium"
|
|
||||||
title={parsed.endpoint}
|
|
||||||
>
|
|
||||||
{parsed.endpoint}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Meta row: time + size */}
|
|
||||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/80 mt-1">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{formatRelativeTime(modified)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<FileText className="w-3 h-3" />
|
|
||||||
{formatBytes(size)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorLogsMonitor() {
|
|
||||||
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
|
|
||||||
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
|
|
||||||
|
|
||||||
// Vertical resize state
|
|
||||||
const [height, setHeight] = useState(500);
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Auto-scroll handler
|
|
||||||
const stopAutoScroll = () => {
|
|
||||||
if (scrollIntervalRef.current) {
|
|
||||||
clearInterval(scrollIntervalRef.current);
|
|
||||||
scrollIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resize handlers
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isResizing) return;
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const containerTopDoc = rect.top + window.scrollY;
|
|
||||||
const newHeight = e.pageY - containerTopDoc;
|
|
||||||
|
|
||||||
// Constrain height (min 300, no max)
|
|
||||||
setHeight(Math.max(300, newHeight));
|
|
||||||
|
|
||||||
// Auto-scroll logic
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const distFromBottom = viewportHeight - e.clientY;
|
|
||||||
const scrollSpeed = 15;
|
|
||||||
|
|
||||||
stopAutoScroll();
|
|
||||||
|
|
||||||
if (distFromBottom < 50) {
|
|
||||||
scrollIntervalRef.current = setInterval(() => {
|
|
||||||
window.scrollBy(0, scrollSpeed);
|
|
||||||
}, 16);
|
|
||||||
} else if (e.clientY < 50) {
|
|
||||||
scrollIntervalRef.current = setInterval(() => {
|
|
||||||
window.scrollBy(0, -scrollSpeed);
|
|
||||||
}, 16);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsResizing(false);
|
|
||||||
stopAutoScroll();
|
|
||||||
document.body.style.cursor = 'default';
|
|
||||||
document.body.style.userSelect = 'auto';
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
|
||||||
document.body.style.cursor = 'row-resize';
|
|
||||||
document.body.style.userSelect = 'none';
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
document.body.style.cursor = 'default';
|
|
||||||
document.body.style.userSelect = 'auto';
|
|
||||||
stopAutoScroll();
|
|
||||||
};
|
|
||||||
}, [isResizing]);
|
|
||||||
|
|
||||||
const startResizing = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsResizing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compute default selection (first log name or null)
|
|
||||||
const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Get absolute path for the selected log
|
|
||||||
const selectedAbsolutePath = useMemo(() => {
|
|
||||||
if (!effectiveSelection || !logs) return undefined;
|
|
||||||
const log = logs.find((l) => l.name === effectiveSelection);
|
|
||||||
return log?.absolutePath;
|
|
||||||
}, [effectiveSelection, logs]);
|
|
||||||
|
|
||||||
// Guards
|
|
||||||
if (isStatusLoading) return null;
|
|
||||||
if (!status?.running) return null;
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border overflow-hidden font-mono text-sm bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm h-[500px]">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!logs || logs.length === 0) return null;
|
|
||||||
|
|
||||||
const errorCount = logs.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="rounded-xl border border-border overflow-hidden font-mono text-sm text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm flex flex-col shadow-sm transition-[height] duration-0 ease-linear relative group/container"
|
|
||||||
style={{ height }}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
|
||||||
<span className="text-sm font-semibold tracking-tight">Error Logs</span>
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
|
||||||
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<FileWarning className="w-3.5 h-3.5" />
|
|
||||||
<span>CLIProxy Diagnostics</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resizable Panel Layout */}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<PanelGroup direction="horizontal">
|
|
||||||
{/* Left Panel: Log List */}
|
|
||||||
<Panel defaultSize={30} minSize={20} maxSize={50} className="flex flex-col min-w-0">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="divide-y divide-border/50">
|
|
||||||
{logs.slice(0, 50).map((log) => (
|
|
||||||
<ErrorLogItem
|
|
||||||
key={log.name}
|
|
||||||
name={log.name}
|
|
||||||
size={log.size}
|
|
||||||
modified={log.modified}
|
|
||||||
isSelected={effectiveSelection === log.name}
|
|
||||||
onClick={() => setSelectedLog(log.name)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{logs.length > 50 && (
|
|
||||||
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
|
|
||||||
Showing 50 of {logs.length} logs
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Resize Handle */}
|
|
||||||
<PanelResizeHandle className="w-[1px] bg-border hover:bg-primary/50 transition-colors flex items-center justify-center group relative z-10 w-2 -ml-1 flex items-center justify-center outline-none">
|
|
||||||
<div className="w-[1px] h-full bg-border group-hover:bg-primary/50 transition-colors" />
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 rounded-sm flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-muted border border-border">
|
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</PanelResizeHandle>
|
|
||||||
|
|
||||||
{/* Right Panel: Log Content */}
|
|
||||||
<Panel className="flex flex-col min-w-0 bg-background/50">
|
|
||||||
<LogContentPanel name={effectiveSelection} absolutePath={selectedAbsolutePath} />
|
|
||||||
</Panel>
|
|
||||||
</PanelGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Use standard footer if error, otherwise show resize handle */}
|
|
||||||
{error ? (
|
|
||||||
<div className="px-4 py-2 border-t border-border text-xs text-destructive bg-destructive/5 shrink-0">
|
|
||||||
{error.message}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="h-2 bg-border/10 border-t border-border/30 hover:bg-primary/10 transition-colors cursor-row-resize flex items-center justify-center group/handle shrink-0"
|
|
||||||
onMouseDown={startResizing}
|
|
||||||
>
|
|
||||||
<GripHorizontal className="w-8 h-3 text-border group-hover:text-primary/50 transition-colors" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
/**
|
||||||
|
* Auth Monitor Component with Account Flow Visualization
|
||||||
|
* Shows request flow from accounts to providers using custom SVG bezier curves
|
||||||
|
* Uses glass panel aesthetic with hover interactions and glow effects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||||
|
import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats';
|
||||||
|
import { cn, STATUS_COLORS } from '@/lib/utils';
|
||||||
|
import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||||
|
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||||
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
|
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||||
|
import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AccountRow {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
provider: string;
|
||||||
|
displayName: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderStats {
|
||||||
|
provider: string;
|
||||||
|
displayName: string;
|
||||||
|
totalRequests: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
accountCount: number;
|
||||||
|
accounts: AccountRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSuccessRate(success: number, failure: number): number {
|
||||||
|
const total = success + failure;
|
||||||
|
if (total === 0) return 100;
|
||||||
|
return Math.round((success / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip common email domains for cleaner display */
|
||||||
|
function cleanEmail(email: string): string {
|
||||||
|
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vibrant colors for account segments - darker for light theme contrast
|
||||||
|
const ACCOUNT_COLORS = [
|
||||||
|
'#1e6091', // Deep Cerulean (was #277da1)
|
||||||
|
'#2d8a6e', // Deep Seaweed (was #43aa8b)
|
||||||
|
'#d4a012', // Dark Tuscan (was #f9c74f)
|
||||||
|
'#c92a2d', // Deep Strawberry (was #f94144)
|
||||||
|
'#c45a1a', // Deep Pumpkin (was #f3722c)
|
||||||
|
'#6b9c4d', // Dark Willow (was #90be6d)
|
||||||
|
'#3d5a73', // Deep Blue Slate (was #577590)
|
||||||
|
'#cc7614', // Dark Carrot (was #f8961e)
|
||||||
|
'#3a7371', // Deep Cyan (was #4d908e)
|
||||||
|
'#7c5fc4', // Deep Purple (was #a78bfa)
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Enhanced live pulse indicator with multi-ring animation */
|
||||||
|
function LivePulse() {
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-5 h-5">
|
||||||
|
{/* Outer ping ring */}
|
||||||
|
<div
|
||||||
|
className="absolute w-4 h-4 rounded-full animate-ping opacity-20"
|
||||||
|
style={{ backgroundColor: STATUS_COLORS.success }}
|
||||||
|
/>
|
||||||
|
{/* Middle pulse ring */}
|
||||||
|
<div
|
||||||
|
className="absolute w-3 h-3 rounded-full animate-pulse opacity-40"
|
||||||
|
style={{ backgroundColor: STATUS_COLORS.success }}
|
||||||
|
/>
|
||||||
|
{/* Inner solid dot */}
|
||||||
|
<div
|
||||||
|
className="relative w-2 h-2 rounded-full z-10"
|
||||||
|
style={{ backgroundColor: STATUS_COLORS.success }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline success/failure badge for provider cards */
|
||||||
|
function InlineStatsBadge({ success, failure }: { success: number; failure: number }) {
|
||||||
|
if (success === 0 && failure === 0) {
|
||||||
|
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-700 dark:text-emerald-500" />
|
||||||
|
<span className="text-[10px] font-mono font-medium text-emerald-700 dark:text-emerald-500">
|
||||||
|
{success.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{failure > 0 && (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<XCircle className="w-3 h-3 text-red-700 dark:text-red-500" />
|
||||||
|
<span className="text-[10px] font-mono font-medium text-red-700 dark:text-red-500">
|
||||||
|
{failure.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthMonitor() {
|
||||||
|
const { data, isLoading, error } = useCliproxyAuth();
|
||||||
|
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||||
|
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
|
||||||
|
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
|
||||||
|
|
||||||
|
// Live countdown showing time since last data update
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataUpdatedAt) return;
|
||||||
|
const updateTime = () => {
|
||||||
|
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
|
||||||
|
if (diff < 60) {
|
||||||
|
setTimeSinceUpdate(`${diff}s ago`);
|
||||||
|
} else {
|
||||||
|
setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateTime();
|
||||||
|
const interval = setInterval(updateTime, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [dataUpdatedAt]);
|
||||||
|
|
||||||
|
// Build a map of account email -> usage stats from CLIProxy
|
||||||
|
const accountStatsMap = useMemo(() => {
|
||||||
|
if (!statsData?.accountStats) return new Map<string, AccountUsageStats>();
|
||||||
|
return new Map(Object.entries(statsData.accountStats));
|
||||||
|
}, [statsData?.accountStats]);
|
||||||
|
|
||||||
|
// Transform auth status data into account rows
|
||||||
|
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
|
||||||
|
if (!data?.authStatus) {
|
||||||
|
return {
|
||||||
|
accounts: [],
|
||||||
|
totalSuccess: 0,
|
||||||
|
totalFailure: 0,
|
||||||
|
totalRequests: 0,
|
||||||
|
providerStats: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountsList: AccountRow[] = [];
|
||||||
|
const providerMap = new Map<
|
||||||
|
string,
|
||||||
|
{ success: number; failure: number; accounts: AccountRow[] }
|
||||||
|
>();
|
||||||
|
let tSuccess = 0;
|
||||||
|
let tFailure = 0;
|
||||||
|
let colorIndex = 0;
|
||||||
|
|
||||||
|
data.authStatus.forEach((status: AuthStatus) => {
|
||||||
|
const providerKey = status.provider;
|
||||||
|
if (!providerMap.has(providerKey)) {
|
||||||
|
providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] });
|
||||||
|
}
|
||||||
|
const providerData = providerMap.get(providerKey);
|
||||||
|
if (!providerData) return;
|
||||||
|
|
||||||
|
status.accounts?.forEach((account: OAuthAccount) => {
|
||||||
|
// Get real stats from CLIProxy - try email first, then id
|
||||||
|
const accountEmail = account.email || account.id;
|
||||||
|
const realStats = accountStatsMap.get(accountEmail);
|
||||||
|
const success = realStats?.successCount ?? 0;
|
||||||
|
const failure = realStats?.failureCount ?? 0;
|
||||||
|
tSuccess += success;
|
||||||
|
tFailure += failure;
|
||||||
|
providerData.success += success;
|
||||||
|
providerData.failure += failure;
|
||||||
|
|
||||||
|
const row: AccountRow = {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email || account.id,
|
||||||
|
provider: status.provider,
|
||||||
|
displayName: status.displayName,
|
||||||
|
isDefault: account.isDefault,
|
||||||
|
successCount: success,
|
||||||
|
failureCount: failure,
|
||||||
|
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
||||||
|
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
||||||
|
};
|
||||||
|
accountsList.push(row);
|
||||||
|
providerData.accounts.push(row);
|
||||||
|
colorIndex++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build provider stats array
|
||||||
|
const providerStatsArr: ProviderStats[] = [];
|
||||||
|
providerMap.forEach((pData, provider) => {
|
||||||
|
if (pData.accounts.length === 0) return;
|
||||||
|
providerStatsArr.push({
|
||||||
|
provider,
|
||||||
|
displayName: getProviderDisplayName(provider),
|
||||||
|
totalRequests: pData.success + pData.failure,
|
||||||
|
successCount: pData.success,
|
||||||
|
failureCount: pData.failure,
|
||||||
|
accountCount: pData.accounts.length,
|
||||||
|
accounts: pData.accounts,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts: accountsList,
|
||||||
|
totalSuccess: tSuccess,
|
||||||
|
totalFailure: tFailure,
|
||||||
|
totalRequests: tSuccess + tFailure,
|
||||||
|
providerStats: providerStatsArr,
|
||||||
|
};
|
||||||
|
}, [data?.authStatus, accountStatsMap]);
|
||||||
|
|
||||||
|
const overallSuccessRate =
|
||||||
|
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
|
||||||
|
|
||||||
|
// Get selected provider data for detail view
|
||||||
|
const selectedProviderData = selectedProvider
|
||||||
|
? providerStats.find((ps) => ps.provider === selectedProvider)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isLoading || statsLoading) {
|
||||||
|
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-3 border-b border-border">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-16 flex-1 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-48 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data?.authStatus || accounts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* Enhanced Live Header with gradient glow */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LivePulse />
|
||||||
|
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Radio className="w-3 h-3 animate-pulse" />
|
||||||
|
<span>Updated {timeSinceUpdate || 'now'}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground/50">|</span>
|
||||||
|
<span>{accounts.length} accounts</span>
|
||||||
|
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats Row */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
|
||||||
|
<SummaryCard
|
||||||
|
icon={<Activity className="w-4 h-4" />}
|
||||||
|
label="Accounts"
|
||||||
|
value={accounts.length}
|
||||||
|
color="var(--accent)"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon={<CheckCircle2 className="w-4 h-4" />}
|
||||||
|
label="Success"
|
||||||
|
value={totalSuccess.toLocaleString()}
|
||||||
|
color={STATUS_COLORS.success}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon={<XCircle className="w-4 h-4" />}
|
||||||
|
label="Failed"
|
||||||
|
value={totalFailure.toLocaleString()}
|
||||||
|
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon={<Activity className="w-4 h-4" />}
|
||||||
|
label="Success Rate"
|
||||||
|
value={`${overallSuccessRate}%`}
|
||||||
|
color={
|
||||||
|
overallSuccessRate === 100
|
||||||
|
? STATUS_COLORS.success
|
||||||
|
: overallSuccessRate >= 95
|
||||||
|
? STATUS_COLORS.degraded
|
||||||
|
: STATUS_COLORS.failed
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flow Visualization */}
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
{selectedProviderData ? (
|
||||||
|
// Account-level flow view
|
||||||
|
<AccountFlowViz
|
||||||
|
providerData={selectedProviderData}
|
||||||
|
onBack={() => setSelectedProvider(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Provider cards view
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mb-4">
|
||||||
|
Request Distribution by Provider
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{providerStats.map((ps) => {
|
||||||
|
const successRate = getSuccessRate(ps.successCount, ps.failureCount);
|
||||||
|
const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280';
|
||||||
|
const isHovered = hoveredProvider === ps.provider;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ps.provider}
|
||||||
|
onClick={() => setSelectedProvider(ps.provider)}
|
||||||
|
onMouseEnter={() => setHoveredProvider(ps.provider)}
|
||||||
|
onMouseLeave={() => setHoveredProvider(null)}
|
||||||
|
className={cn(
|
||||||
|
'group relative rounded-xl p-4 text-left transition-all duration-300',
|
||||||
|
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||||
|
'border border-border/50 dark:border-white/[0.08]',
|
||||||
|
'hover:border-opacity-50 hover:scale-[1.02] hover:shadow-lg',
|
||||||
|
isHovered && 'ring-1'
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
borderColor: isHovered ? providerColor : undefined,
|
||||||
|
'--ring-color': providerColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<ProviderIcon provider={ps.provider} size={36} withBackground />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground tracking-tight">
|
||||||
|
{ps.displayName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{ps.accountCount} account{ps.accountCount !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 ml-auto text-muted-foreground transition-all',
|
||||||
|
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Inline success/failure stats - immediately visible */}
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-muted-foreground">Stats</span>
|
||||||
|
<InlineStatsBadge success={ps.successCount} failure={ps.failureCount} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Success Rate</span>
|
||||||
|
<span
|
||||||
|
className="font-mono font-semibold"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
successRate === 100
|
||||||
|
? STATUS_COLORS.success
|
||||||
|
: successRate >= 95
|
||||||
|
? STATUS_COLORS.degraded
|
||||||
|
: STATUS_COLORS.failed,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{successRate}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${successRate}%`,
|
||||||
|
backgroundColor: providerColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account color dots */}
|
||||||
|
<div className="flex gap-1 mt-3">
|
||||||
|
{ps.accounts.slice(0, 5).map((acc) => (
|
||||||
|
<div
|
||||||
|
key={acc.id}
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: acc.color }}
|
||||||
|
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{ps.accounts.length > 5 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1">
|
||||||
|
+{ps.accounts.length - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary Card Component
|
||||||
|
function SummaryCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-card/50 dark:bg-zinc-900/50 border border-border/50 dark:border-white/[0.06]">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-md flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color ? `${color}15` : 'var(--muted)',
|
||||||
|
color: color || 'var(--muted-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</div>
|
||||||
|
<div
|
||||||
|
className="text-lg font-semibold font-mono leading-tight"
|
||||||
|
style={{ color: color || 'var(--foreground)' }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Error Log Item Component
|
||||||
|
* Individual log entry in the list view
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Clock, FileText } from 'lucide-react';
|
||||||
|
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||||
|
import { parseFilename, formatRelativeTime, formatBytes } from '@/lib/error-log-parser';
|
||||||
|
import type { ErrorLogItemProps } from './types';
|
||||||
|
|
||||||
|
export function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
|
||||||
|
const parsed = useMemo(() => parseFilename(name), [name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2.5 flex items-start gap-3 text-left transition-colors',
|
||||||
|
'hover:bg-muted/40 border-l-[3px]',
|
||||||
|
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Provider Icon */}
|
||||||
|
<ProviderIcon
|
||||||
|
provider={parsed.provider}
|
||||||
|
size={24}
|
||||||
|
withBackground={true}
|
||||||
|
className="shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
{/* Provider / Endpoint */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold text-foreground truncate">
|
||||||
|
{parsed.provider}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[9px] px-1 rounded border',
|
||||||
|
isSelected
|
||||||
|
? 'bg-amber-500/10 text-amber-600 border-amber-500/20'
|
||||||
|
: 'bg-muted border-border text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
LOG
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-[11px] text-muted-foreground truncate font-medium"
|
||||||
|
title={parsed.endpoint}
|
||||||
|
>
|
||||||
|
{parsed.endpoint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta row: time + size */}
|
||||||
|
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/80 mt-1">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatRelativeTime(modified)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
{formatBytes(size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Error Logs Monitor Component
|
||||||
|
*
|
||||||
|
* Displays CLIProxyAPI error logs with master-detail split view.
|
||||||
|
* ETL: Parses raw logs into structured data for rich display.
|
||||||
|
* - Left panel: Log list with status code, provider, endpoint, relative time
|
||||||
|
* - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
|
import { useCliproxyErrorLogs } from '@/hooks/use-cliproxy-stats';
|
||||||
|
import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react';
|
||||||
|
import { ErrorLogItem } from './error-log-item';
|
||||||
|
import { LogContentPanel } from './log-content-panel';
|
||||||
|
|
||||||
|
export function ErrorLogsMonitor() {
|
||||||
|
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
|
||||||
|
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
|
||||||
|
|
||||||
|
// Vertical resize state
|
||||||
|
const [height, setHeight] = useState(500);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Auto-scroll handler
|
||||||
|
const stopAutoScroll = () => {
|
||||||
|
if (scrollIntervalRef.current) {
|
||||||
|
clearInterval(scrollIntervalRef.current);
|
||||||
|
scrollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resize handlers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const containerTopDoc = rect.top + window.scrollY;
|
||||||
|
const newHeight = e.pageY - containerTopDoc;
|
||||||
|
|
||||||
|
// Constrain height (min 300, no max)
|
||||||
|
setHeight(Math.max(300, newHeight));
|
||||||
|
|
||||||
|
// Auto-scroll logic
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const distFromBottom = viewportHeight - e.clientY;
|
||||||
|
const scrollSpeed = 15;
|
||||||
|
|
||||||
|
stopAutoScroll();
|
||||||
|
|
||||||
|
if (distFromBottom < 50) {
|
||||||
|
scrollIntervalRef.current = setInterval(() => {
|
||||||
|
window.scrollBy(0, scrollSpeed);
|
||||||
|
}, 16);
|
||||||
|
} else if (e.clientY < 50) {
|
||||||
|
scrollIntervalRef.current = setInterval(() => {
|
||||||
|
window.scrollBy(0, -scrollSpeed);
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
stopAutoScroll();
|
||||||
|
document.body.style.cursor = 'default';
|
||||||
|
document.body.style.userSelect = 'auto';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.body.style.cursor = 'row-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.body.style.cursor = 'default';
|
||||||
|
document.body.style.userSelect = 'auto';
|
||||||
|
stopAutoScroll();
|
||||||
|
};
|
||||||
|
}, [isResizing]);
|
||||||
|
|
||||||
|
const startResizing = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute default selection (first log name or null)
|
||||||
|
const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Get absolute path for the selected log
|
||||||
|
const selectedAbsolutePath = useMemo(() => {
|
||||||
|
if (!effectiveSelection || !logs) return undefined;
|
||||||
|
const log = logs.find((l) => l.name === effectiveSelection);
|
||||||
|
return log?.absolutePath;
|
||||||
|
}, [effectiveSelection, logs]);
|
||||||
|
|
||||||
|
// Guards
|
||||||
|
if (isStatusLoading) return null;
|
||||||
|
if (!status?.running) return null;
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border overflow-hidden font-mono text-sm bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm h-[500px]">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!logs || logs.length === 0) return null;
|
||||||
|
|
||||||
|
const errorCount = logs.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="rounded-xl border border-border overflow-hidden font-mono text-sm text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm flex flex-col shadow-sm transition-[height] duration-0 ease-linear relative group/container"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||||
|
<span className="text-sm font-semibold tracking-tight">Error Logs</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
|
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<FileWarning className="w-3.5 h-3.5" />
|
||||||
|
<span>CLIProxy Diagnostics</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resizable Panel Layout */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<PanelGroup direction="horizontal">
|
||||||
|
{/* Left Panel: Log List */}
|
||||||
|
<Panel defaultSize={30} minSize={20} maxSize={50} className="flex flex-col min-w-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{logs.slice(0, 50).map((log) => (
|
||||||
|
<ErrorLogItem
|
||||||
|
key={log.name}
|
||||||
|
name={log.name}
|
||||||
|
size={log.size}
|
||||||
|
modified={log.modified}
|
||||||
|
isSelected={effectiveSelection === log.name}
|
||||||
|
onClick={() => setSelectedLog(log.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{logs.length > 50 && (
|
||||||
|
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
|
||||||
|
Showing 50 of {logs.length} logs
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Resize Handle */}
|
||||||
|
<PanelResizeHandle className="w-[1px] bg-border hover:bg-primary/50 transition-colors flex items-center justify-center group relative z-10 w-2 -ml-1 flex items-center justify-center outline-none">
|
||||||
|
<div className="w-[1px] h-full bg-border group-hover:bg-primary/50 transition-colors" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 rounded-sm flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-muted border border-border">
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</PanelResizeHandle>
|
||||||
|
|
||||||
|
{/* Right Panel: Log Content */}
|
||||||
|
<Panel className="flex flex-col min-w-0 bg-background/50">
|
||||||
|
<LogContentPanel name={effectiveSelection} absolutePath={selectedAbsolutePath} />
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Use standard footer if error, otherwise show resize handle */}
|
||||||
|
{error ? (
|
||||||
|
<div className="px-4 py-2 border-t border-border text-xs text-destructive bg-destructive/5 shrink-0">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 bg-border/10 border-t border-border/30 hover:bg-primary/10 transition-colors cursor-row-resize flex items-center justify-center group/handle shrink-0"
|
||||||
|
onMouseDown={startResizing}
|
||||||
|
>
|
||||||
|
<GripHorizontal className="w-8 h-3 text-border group-hover:text-primary/50 transition-colors" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export components
|
||||||
|
export { ErrorLogItem } from './error-log-item';
|
||||||
|
export { LogContentPanel } from './log-content-panel';
|
||||||
|
export { TabButton, StatusBadge } from './ui-primitives';
|
||||||
|
export { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components';
|
||||||
|
export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './types';
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Log Content Panel Component
|
||||||
|
* Detail view with tabbed navigation for error log content
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
|
import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react';
|
||||||
|
import { parseErrorLog } from '@/lib/error-log-parser';
|
||||||
|
import type { TabType, LogContentPanelProps } from './types';
|
||||||
|
import { TabButton, StatusBadge } from './ui-primitives';
|
||||||
|
import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components';
|
||||||
|
|
||||||
|
export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
|
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
|
||||||
|
|
||||||
|
// Parse log content
|
||||||
|
const parsed = useMemo(() => {
|
||||||
|
if (!content) return null;
|
||||||
|
return parseErrorLog(content);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
// No log selected
|
||||||
|
if (!name) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<Terminal className="w-10 h-10 mx-auto opacity-40" />
|
||||||
|
<p className="text-sm">Select a log to view details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 p-6 space-y-3">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
<Skeleton className="h-5 w-3/4" />
|
||||||
|
<Skeleton className="h-5 w-5/6" />
|
||||||
|
<Skeleton className="h-5 w-2/3" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error or no content
|
||||||
|
if (error || !content || !parsed) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
<p className="text-sm">Failed to load log content</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 h-full">
|
||||||
|
{/* Header with status */}
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center justify-between gap-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<StatusBadge code={parsed.statusCode} />
|
||||||
|
<span className="text-xs font-semibold truncate text-foreground">
|
||||||
|
{parsed.provider}/{parsed.endpoint || 'unknown'}
|
||||||
|
</span>
|
||||||
|
{/* Copy Absolute Path Button */}
|
||||||
|
{name && (
|
||||||
|
<CopyButton
|
||||||
|
value={absolutePath || name}
|
||||||
|
label="Copy absolute path"
|
||||||
|
size="icon-sm"
|
||||||
|
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{/* Copy Raw Content Button */}
|
||||||
|
{content && (
|
||||||
|
<CopyButton
|
||||||
|
value={content}
|
||||||
|
label="Copy raw log content"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded border border-border/50">
|
||||||
|
{parsed.method}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="px-3 py-2 border-b border-border flex items-center gap-1 bg-muted/10 shrink-0 overflow-x-auto">
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'overview'}
|
||||||
|
onClick={() => setActiveTab('overview')}
|
||||||
|
icon={Info}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</TabButton>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'headers'}
|
||||||
|
onClick={() => setActiveTab('headers')}
|
||||||
|
icon={Code}
|
||||||
|
>
|
||||||
|
Headers
|
||||||
|
</TabButton>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'request'}
|
||||||
|
onClick={() => setActiveTab('request')}
|
||||||
|
icon={ArrowUpRight}
|
||||||
|
>
|
||||||
|
Request
|
||||||
|
</TabButton>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'response'}
|
||||||
|
onClick={() => setActiveTab('response')}
|
||||||
|
icon={ArrowDownLeft}
|
||||||
|
>
|
||||||
|
Response
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
|
||||||
|
Raw
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="flex-1 overflow-hidden bg-card/30">
|
||||||
|
{activeTab === 'overview' && <OverviewTab parsed={parsed} />}
|
||||||
|
{activeTab === 'headers' && <HeadersTab headers={parsed.requestHeaders} />}
|
||||||
|
{activeTab === 'request' && <BodyTab content={parsed.requestBody} label="Request" />}
|
||||||
|
{activeTab === 'response' && <BodyTab content={parsed.responseBody} label="Response" />}
|
||||||
|
{activeTab === 'raw' && <RawTab content={content} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Tab Content Components for Error Logs Monitor
|
||||||
|
* OverviewTab, HeadersTab, BodyTab, RawTab
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getErrorTypeLabel, type ParsedErrorLog } from '@/lib/error-log-parser';
|
||||||
|
import { StatusBadge } from './ui-primitives';
|
||||||
|
|
||||||
|
/** Overview tab content */
|
||||||
|
export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Status row */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge code={parsed.statusCode} />
|
||||||
|
<span className="text-sm font-medium">{parsed.statusText}</span>
|
||||||
|
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
|
||||||
|
{getErrorTypeLabel(parsed.errorType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key metrics grid */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 text-xs">
|
||||||
|
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||||
|
<div className="text-muted-foreground mb-1">Method</div>
|
||||||
|
<div className="font-medium">{parsed.method || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||||
|
<div className="text-muted-foreground mb-1">Provider</div>
|
||||||
|
<div className="font-medium">{parsed.provider || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||||
|
<div className="text-muted-foreground mb-1">Version</div>
|
||||||
|
<div className="font-medium">{parsed.version || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||||
|
<div className="text-muted-foreground mb-1">Endpoint</div>
|
||||||
|
<div className="font-medium truncate" title={parsed.endpoint}>
|
||||||
|
{parsed.endpoint || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="text-muted-foreground mb-1.5">URL</div>
|
||||||
|
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
|
||||||
|
{parsed.url || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamp */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="text-muted-foreground mb-1.5">Timestamp</div>
|
||||||
|
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestion based on error type */}
|
||||||
|
{parsed.errorType !== 'unknown' && (
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded bg-blue-500/10 border border-blue-500/20 text-xs">
|
||||||
|
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
|
||||||
|
<div className="text-blue-500/90 leading-relaxed">
|
||||||
|
{parsed.errorType === 'rate_limit' &&
|
||||||
|
'Rate limited. Consider using multiple accounts or reducing request frequency.'}
|
||||||
|
{parsed.errorType === 'auth' &&
|
||||||
|
'Authentication failed. Check credentials or re-authenticate with the provider.'}
|
||||||
|
{parsed.errorType === 'not_found' &&
|
||||||
|
'Endpoint not found. This endpoint may not exist on this provider.'}
|
||||||
|
{parsed.errorType === 'server' &&
|
||||||
|
'Server error from upstream. Retry or check provider status.'}
|
||||||
|
{parsed.errorType === 'timeout' &&
|
||||||
|
'Request timed out. Check network or increase timeout settings.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Headers tab content */
|
||||||
|
export function HeadersTab({ headers }: { headers: Record<string, string> }) {
|
||||||
|
const entries = Object.entries(headers);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <div className="p-4 text-xs text-muted-foreground">No headers available</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-4 space-y-1">
|
||||||
|
{entries.map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex gap-3 text-xs font-mono py-1.5 border-b border-border/30 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground shrink-0 min-w-[140px]">{key}:</span>
|
||||||
|
<span className="break-all">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON/Body tab content */
|
||||||
|
export function BodyTab({ content, label }: { content: string; label: string }) {
|
||||||
|
if (!content || content.trim() === '') {
|
||||||
|
return <div className="p-4 text-xs text-muted-foreground">No {label.toLowerCase()} body</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to format as JSON
|
||||||
|
let formatted = content;
|
||||||
|
let isJson = false;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
formatted = JSON.stringify(parsed, null, 2);
|
||||||
|
isJson = true;
|
||||||
|
} catch {
|
||||||
|
// Not JSON, use as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
'p-4 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed',
|
||||||
|
isJson
|
||||||
|
? 'text-emerald-700 dark:text-green-400'
|
||||||
|
: 'text-zinc-700 dark:text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatted}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw tab content */
|
||||||
|
export function RawTab({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<pre className="p-4 text-xs font-mono text-zinc-700 dark:text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Types for Error Logs Monitor
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw';
|
||||||
|
|
||||||
|
export interface ErrorLogItemProps {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
modified: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogContentPanelProps {
|
||||||
|
name: string | null;
|
||||||
|
absolutePath?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* UI Primitives for Error Logs Monitor
|
||||||
|
* TabButton and StatusBadge components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getStatusColor } from '@/lib/error-log-parser';
|
||||||
|
|
||||||
|
/** Tab button component */
|
||||||
|
export function TabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'px-2.5 py-1.5 text-xs font-medium rounded transition-colors flex items-center gap-1.5',
|
||||||
|
active
|
||||||
|
? 'bg-primary/15 text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="w-3.5 h-3.5" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status badge component */
|
||||||
|
export function StatusBadge({ code }: { code: number }) {
|
||||||
|
const colorClass = getStatusColor(code);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center min-w-[36px] px-2 py-0.5 rounded text-xs font-bold',
|
||||||
|
'bg-current/10 border border-current/20',
|
||||||
|
colorClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Monitoring Components Barrel Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Main monitoring components
|
||||||
|
export { AuthMonitor } from './auth-monitor';
|
||||||
|
export { ProxyStatusWidget } from './proxy-status-widget';
|
||||||
|
|
||||||
|
// Error logs (from subdirectory)
|
||||||
|
export { ErrorLogsMonitor } from './error-logs';
|
||||||
|
export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './error-logs';
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Proxy Status Widget
|
||||||
|
*
|
||||||
|
* Displays CLIProxy process status with start/stop/restart controls.
|
||||||
|
* Shows: running state, port, session count, uptime, update availability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
useProxyStatus,
|
||||||
|
useStartProxy,
|
||||||
|
useStopProxy,
|
||||||
|
useCliproxyUpdateCheck,
|
||||||
|
} from '@/hooks/use-cliproxy';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function formatUptime(startedAt?: string): string {
|
||||||
|
if (!startedAt) return '';
|
||||||
|
const start = new Date(startedAt).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - start;
|
||||||
|
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(timestamp?: number): string {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const diff = Date.now() - timestamp;
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
|
||||||
|
if (minutes < 1) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProxyStatusWidget() {
|
||||||
|
const { data: status, isLoading } = useProxyStatus();
|
||||||
|
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||||
|
const startProxy = useStartProxy();
|
||||||
|
const stopProxy = useStopProxy();
|
||||||
|
|
||||||
|
const isRunning = status?.running ?? false;
|
||||||
|
const isActioning = startProxy.isPending || stopProxy.isPending;
|
||||||
|
const hasUpdate = updateCheck?.hasUpdate ?? false;
|
||||||
|
|
||||||
|
// Restart = stop then start
|
||||||
|
const handleRestart = async () => {
|
||||||
|
await stopProxy.mutateAsync();
|
||||||
|
// Small delay to ensure port is released
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
startProxy.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border p-3 transition-colors',
|
||||||
|
isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">CLIProxy Service</span>
|
||||||
|
{hasUpdate && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] h-4 px-1.5 gap-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
|
title={`Update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`}
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-2.5 h-2.5" />
|
||||||
|
Update
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||||
|
) : isRunning ? (
|
||||||
|
<Activity className="w-3 h-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Power className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRunning && status ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">Port {status.port}</span>
|
||||||
|
{status.sessionCount !== undefined && status.sessionCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status.startedAt && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatUptime(status.startedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Control buttons when running */}
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={hasUpdate ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-7 text-xs gap-1 flex-1',
|
||||||
|
hasUpdate &&
|
||||||
|
'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
|
||||||
|
)}
|
||||||
|
onClick={handleRestart}
|
||||||
|
disabled={isActioning}
|
||||||
|
title={
|
||||||
|
hasUpdate
|
||||||
|
? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`
|
||||||
|
: 'Restart CLIProxy service'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isActioning ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
) : hasUpdate ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
{hasUpdate ? 'Update' : 'Restart'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30"
|
||||||
|
onClick={() => stopProxy.mutate()}
|
||||||
|
disabled={isActioning}
|
||||||
|
title="Stop CLIProxy service"
|
||||||
|
>
|
||||||
|
{stopProxy.isPending ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">Not running</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1"
|
||||||
|
onClick={() => startProxy.mutate()}
|
||||||
|
disabled={startProxy.isPending}
|
||||||
|
>
|
||||||
|
{startProxy.isPending ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Power className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version sync indicator */}
|
||||||
|
{updateCheck?.currentVersion && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-muted flex items-center justify-between text-[10px] text-muted-foreground/70">
|
||||||
|
<span>v{updateCheck.currentVersion}</span>
|
||||||
|
{updateCheck.checkedAt && (
|
||||||
|
<span title={new Date(updateCheck.checkedAt).toLocaleString()}>
|
||||||
|
Synced {formatTimeAgo(updateCheck.checkedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user