From 946030c8363ed5e4ac6ebe8b6860d0f8fb006c41 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 19:40:43 -0500 Subject: [PATCH] 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 --- ui/src/components/error-logs-monitor.tsx | 627 +----------------- ui/src/components/monitoring/auth-monitor.tsx | 465 +++++++++++++ .../monitoring/error-logs/error-log-item.tsx | 73 ++ .../monitoring/error-logs/index.tsx | 219 ++++++ .../error-logs/log-content-panel.tsx | 140 ++++ .../monitoring/error-logs/tab-components.tsx | 149 +++++ .../components/monitoring/error-logs/types.ts | 18 + .../monitoring/error-logs/ui-primitives.tsx | 51 ++ ui/src/components/monitoring/index.ts | 11 + .../monitoring/proxy-status-widget.tsx | 196 ++++++ 10 files changed, 1336 insertions(+), 613 deletions(-) create mode 100644 ui/src/components/monitoring/auth-monitor.tsx create mode 100644 ui/src/components/monitoring/error-logs/error-log-item.tsx create mode 100644 ui/src/components/monitoring/error-logs/index.tsx create mode 100644 ui/src/components/monitoring/error-logs/log-content-panel.tsx create mode 100644 ui/src/components/monitoring/error-logs/tab-components.tsx create mode 100644 ui/src/components/monitoring/error-logs/types.ts create mode 100644 ui/src/components/monitoring/error-logs/ui-primitives.tsx create mode 100644 ui/src/components/monitoring/index.ts create mode 100644 ui/src/components/monitoring/proxy-status-widget.tsx diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx index eb704397..6974ec81 100644 --- a/ui/src/components/error-logs-monitor.tsx +++ b/ui/src/components/error-logs-monitor.tsx @@ -1,617 +1,18 @@ /** - * 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) + * Error Logs Monitor - Re-export from modular structure + * @deprecated Import from '@/components/monitoring/error-logs' directly */ -import { useState, useMemo, useRef, useEffect } from 'react'; -import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; -import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; -import { cn } from '@/lib/utils'; -import { Skeleton } from '@/components/ui/skeleton'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { ProviderIcon } from '@/components/provider-icon'; -import { CopyButton } from '@/components/ui/copy-button'; -import { - AlertTriangle, - 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'; +export { + ErrorLogsMonitor, + ErrorLogItem, + LogContentPanel, + TabButton, + StatusBadge, + OverviewTab, + HeadersTab, + BodyTab, + RawTab, +} from './monitoring/error-logs'; -type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw'; - -/** Tab button component */ -function TabButton({ - active, - onClick, - children, - icon: Icon, -}: { - active: boolean; - onClick: () => void; - children: React.ReactNode; - icon?: React.ComponentType<{ className?: string }>; -}) { - return ( - - ); -} - -/** Status badge component */ -function StatusBadge({ code }: { code: number }) { - const colorClass = getStatusColor(code); - return ( - - {code} - - ); -} - -/** Overview tab content */ -function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { - return ( -
- {/* Status row */} -
- - {parsed.statusText} - - {getErrorTypeLabel(parsed.errorType)} - -
- - {/* Key metrics grid */} -
-
-
Method
-
{parsed.method || 'N/A'}
-
-
-
Provider
-
{parsed.provider || 'N/A'}
-
-
-
Version
-
{parsed.version || 'N/A'}
-
-
-
Endpoint
-
- {parsed.endpoint || 'N/A'} -
-
-
- - {/* URL */} -
-
URL
-
- {parsed.url || 'N/A'} -
-
- - {/* Timestamp */} -
-
Timestamp
-
{parsed.timestamp || 'N/A'}
-
- - {/* Suggestion based on error type */} - {parsed.errorType !== 'unknown' && ( -
- -
- {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.'} -
-
- )} -
- ); -} - -/** Headers tab content */ -function HeadersTab({ headers }: { headers: Record }) { - const entries = Object.entries(headers); - if (entries.length === 0) { - return
No headers available
; - } - - return ( - -
- {entries.map(([key, value]) => ( -
- {key}: - {value} -
- ))} -
-
- ); -} - -/** JSON/Body tab content */ -function BodyTab({ content, label }: { content: string; label: string }) { - if (!content || content.trim() === '') { - return
No {label.toLowerCase()} body
; - } - - // 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 ( - -
-        {formatted}
-      
-
- ); -} - -/** Raw tab content */ -function RawTab({ content }: { content: string }) { - return ( - -
-        {content}
-      
-
- ); -} - -/** Log content panel with tabs */ -function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) { - const [activeTab, setActiveTab] = useState('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 ( -
-
- -

Select a log to view details

-
-
- ); - } - - // Loading state - if (isLoading) { - return ( -
- - - - -
- ); - } - - // Error or no content - if (error || !content || !parsed) { - return ( -
-

Failed to load log content

-
- ); - } - - return ( -
- {/* Header with status */} -
-
- - - {parsed.provider}/{parsed.endpoint || 'unknown'} - - {/* Copy Absolute Path Button */} - {name && ( - - )} -
-
- {/* Copy Raw Content Button */} - {content && ( - - )} - - {parsed.method} - -
-
- - {/* Tabs */} -
- setActiveTab('overview')} - icon={Info} - > - Overview - - setActiveTab('headers')} - icon={Code} - > - Headers - - setActiveTab('request')} - icon={ArrowUpRight} - > - Request - - setActiveTab('response')} - icon={ArrowDownLeft} - > - Response - - setActiveTab('raw')} icon={FileText}> - Raw - -
- - {/* Tab content */} -
- {activeTab === 'overview' && } - {activeTab === 'headers' && } - {activeTab === 'request' && } - {activeTab === 'response' && } - {activeTab === 'raw' && } -
-
- ); -} - -/** 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 ( - - ); -} - -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(null); - const scrollIntervalRef = useRef(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(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 ( -
-
- - -
-
- {[1, 2, 3].map((i) => ( - - ))} -
-
- ); - } - if (!logs || logs.length === 0) return null; - - const errorCount = logs.length; - - return ( -
- {/* Header */} -
-
- - Error Logs - - {errorCount} failed request{errorCount !== 1 ? 's' : ''} - -
-
- - CLIProxy Diagnostics -
-
- - {/* Resizable Panel Layout */} -
- - {/* Left Panel: Log List */} - - -
- {logs.slice(0, 50).map((log) => ( - setSelectedLog(log.name)} - /> - ))} -
- {logs.length > 50 && ( -
- Showing 50 of {logs.length} logs -
- )} -
-
- - {/* Resize Handle */} - -
-
- -
- - - {/* Right Panel: Log Content */} - - - - -
- - {/* Use standard footer if error, otherwise show resize handle */} - {error ? ( -
- {error.message} -
- ) : ( -
- -
- )} -
- ); -} +export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './monitoring/error-logs'; diff --git a/ui/src/components/monitoring/auth-monitor.tsx b/ui/src/components/monitoring/auth-monitor.tsx new file mode 100644 index 00000000..61cbaf8b --- /dev/null +++ b/ui/src/components/monitoring/auth-monitor.tsx @@ -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 ( +
+ {/* Outer ping ring */} +
+ {/* Middle pulse ring */} +
+ {/* Inner solid dot */} +
+
+ ); +} + +/** Inline success/failure badge for provider cards */ +function InlineStatsBadge({ success, failure }: { success: number; failure: number }) { + if (success === 0 && failure === 0) { + return no activity; + } + + return ( +
+
+ + + {success.toLocaleString()} + +
+ {failure > 0 && ( +
+ + + {failure.toLocaleString()} + +
+ )} +
+ ); +} + +export function AuthMonitor() { + const { data, isLoading, error } = useCliproxyAuth(); + const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats(); + const { privacyMode } = usePrivacy(); + const [selectedProvider, setSelectedProvider] = useState(null); + const [hoveredProvider, setHoveredProvider] = useState(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(); + 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 ( +
+
+ + +
+
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ +
+
+ ); + } + + if (error || !data?.authStatus || accounts.length === 0) { + return null; + } + + return ( +
+ {/* Enhanced Live Header with gradient glow */} +
+
+ + LIVE + Account Monitor +
+
+
+ + Updated {timeSinceUpdate || 'now'} +
+ | + {accounts.length} accounts + {totalRequests.toLocaleString()} req +
+
+ + {/* Summary Stats Row */} +
+ } + label="Accounts" + value={accounts.length} + color="var(--accent)" + /> + } + label="Success" + value={totalSuccess.toLocaleString()} + color={STATUS_COLORS.success} + /> + } + label="Failed" + value={totalFailure.toLocaleString()} + color={totalFailure > 0 ? STATUS_COLORS.failed : undefined} + /> + } + label="Success Rate" + value={`${overallSuccessRate}%`} + color={ + overallSuccessRate === 100 + ? STATUS_COLORS.success + : overallSuccessRate >= 95 + ? STATUS_COLORS.degraded + : STATUS_COLORS.failed + } + /> +
+ + {/* Flow Visualization */} +
+ {selectedProviderData ? ( + // Account-level flow view + setSelectedProvider(null)} + /> + ) : ( + // Provider cards view +
+
+ Request Distribution by Provider +
+
+ {providerStats.map((ps) => { + const successRate = getSuccessRate(ps.successCount, ps.failureCount); + const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280'; + const isHovered = hoveredProvider === ps.provider; + + return ( + + ); + })} +
+
+ )} +
+
+ ); +} + +// Summary Card Component +function SummaryCard({ + icon, + label, + value, + color, +}: { + icon: React.ReactNode; + label: string; + value: string | number; + color?: string; +}) { + return ( +
+
+ {icon} +
+
+
{label}
+
+ {value} +
+
+
+ ); +} diff --git a/ui/src/components/monitoring/error-logs/error-log-item.tsx b/ui/src/components/monitoring/error-logs/error-log-item.tsx new file mode 100644 index 00000000..a4d4b2e1 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/error-log-item.tsx @@ -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 ( + + ); +} diff --git a/ui/src/components/monitoring/error-logs/index.tsx b/ui/src/components/monitoring/error-logs/index.tsx new file mode 100644 index 00000000..b96a65c7 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/index.tsx @@ -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(null); + const scrollIntervalRef = useRef(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(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 ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + if (!logs || logs.length === 0) return null; + + const errorCount = logs.length; + + return ( +
+ {/* Header */} +
+
+ + Error Logs + + {errorCount} failed request{errorCount !== 1 ? 's' : ''} + +
+
+ + CLIProxy Diagnostics +
+
+ + {/* Resizable Panel Layout */} +
+ + {/* Left Panel: Log List */} + + +
+ {logs.slice(0, 50).map((log) => ( + setSelectedLog(log.name)} + /> + ))} +
+ {logs.length > 50 && ( +
+ Showing 50 of {logs.length} logs +
+ )} +
+
+ + {/* Resize Handle */} + +
+
+ +
+ + + {/* Right Panel: Log Content */} + + + + +
+ + {/* Use standard footer if error, otherwise show resize handle */} + {error ? ( +
+ {error.message} +
+ ) : ( +
+ +
+ )} +
+ ); +} + +// 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'; diff --git a/ui/src/components/monitoring/error-logs/log-content-panel.tsx b/ui/src/components/monitoring/error-logs/log-content-panel.tsx new file mode 100644 index 00000000..45804590 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/log-content-panel.tsx @@ -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('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 ( +
+
+ +

Select a log to view details

+
+
+ ); + } + + // Loading state + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + // Error or no content + if (error || !content || !parsed) { + return ( +
+

Failed to load log content

+
+ ); + } + + return ( +
+ {/* Header with status */} +
+
+ + + {parsed.provider}/{parsed.endpoint || 'unknown'} + + {/* Copy Absolute Path Button */} + {name && ( + + )} +
+
+ {/* Copy Raw Content Button */} + {content && ( + + )} + + {parsed.method} + +
+
+ + {/* Tabs */} +
+ setActiveTab('overview')} + icon={Info} + > + Overview + + setActiveTab('headers')} + icon={Code} + > + Headers + + setActiveTab('request')} + icon={ArrowUpRight} + > + Request + + setActiveTab('response')} + icon={ArrowDownLeft} + > + Response + + setActiveTab('raw')} icon={FileText}> + Raw + +
+ + {/* Tab content */} +
+ {activeTab === 'overview' && } + {activeTab === 'headers' && } + {activeTab === 'request' && } + {activeTab === 'response' && } + {activeTab === 'raw' && } +
+
+ ); +} diff --git a/ui/src/components/monitoring/error-logs/tab-components.tsx b/ui/src/components/monitoring/error-logs/tab-components.tsx new file mode 100644 index 00000000..42043731 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/tab-components.tsx @@ -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 ( +
+ {/* Status row */} +
+ + {parsed.statusText} + + {getErrorTypeLabel(parsed.errorType)} + +
+ + {/* Key metrics grid */} +
+
+
Method
+
{parsed.method || 'N/A'}
+
+
+
Provider
+
{parsed.provider || 'N/A'}
+
+
+
Version
+
{parsed.version || 'N/A'}
+
+
+
Endpoint
+
+ {parsed.endpoint || 'N/A'} +
+
+
+ + {/* URL */} +
+
URL
+
+ {parsed.url || 'N/A'} +
+
+ + {/* Timestamp */} +
+
Timestamp
+
{parsed.timestamp || 'N/A'}
+
+ + {/* Suggestion based on error type */} + {parsed.errorType !== 'unknown' && ( +
+ +
+ {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.'} +
+
+ )} +
+ ); +} + +/** Headers tab content */ +export function HeadersTab({ headers }: { headers: Record }) { + const entries = Object.entries(headers); + if (entries.length === 0) { + return
No headers available
; + } + + return ( + +
+ {entries.map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} +
+
+ ); +} + +/** JSON/Body tab content */ +export function BodyTab({ content, label }: { content: string; label: string }) { + if (!content || content.trim() === '') { + return
No {label.toLowerCase()} body
; + } + + // 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 ( + +
+        {formatted}
+      
+
+ ); +} + +/** Raw tab content */ +export function RawTab({ content }: { content: string }) { + return ( + +
+        {content}
+      
+
+ ); +} diff --git a/ui/src/components/monitoring/error-logs/types.ts b/ui/src/components/monitoring/error-logs/types.ts new file mode 100644 index 00000000..0cae04f1 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/types.ts @@ -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; +} diff --git a/ui/src/components/monitoring/error-logs/ui-primitives.tsx b/ui/src/components/monitoring/error-logs/ui-primitives.tsx new file mode 100644 index 00000000..535ad183 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/ui-primitives.tsx @@ -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 ( + + ); +} + +/** Status badge component */ +export function StatusBadge({ code }: { code: number }) { + const colorClass = getStatusColor(code); + return ( + + {code} + + ); +} diff --git a/ui/src/components/monitoring/index.ts b/ui/src/components/monitoring/index.ts new file mode 100644 index 00000000..3d66966d --- /dev/null +++ b/ui/src/components/monitoring/index.ts @@ -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'; diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx new file mode 100644 index 00000000..bfa0a2c3 --- /dev/null +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -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 ( +
+
+
+
+ CLIProxy Service + {hasUpdate && ( + v${updateCheck?.latestVersion}`} + > + + Update + + )} +
+ +
+ {isLoading ? ( + + ) : isRunning ? ( + + ) : ( + + )} +
+
+ + {isRunning && status ? ( + <> +
+ Port {status.port} + {status.sessionCount !== undefined && status.sessionCount > 0 && ( + + + {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} + + )} + {status.startedAt && ( + + + {formatUptime(status.startedAt)} + + )} +
+ {/* Control buttons when running */} +
+ + +
+ + ) : ( +
+ Not running + +
+ )} + + {/* Version sync indicator */} + {updateCheck?.currentVersion && ( +
+ v{updateCheck.currentVersion} + {updateCheck.checkedAt && ( + + Synced {formatTimeAgo(updateCheck.checkedAt)} + + )} +
+ )} +
+ ); +}