diff --git a/ui/index.html b/ui/index.html index bea1d424..e36ed302 100644 --- a/ui/index.html +++ b/ui/index.html @@ -16,6 +16,14 @@ + + + + +
diff --git a/ui/src/components/health-check-item.tsx b/ui/src/components/health-check-item.tsx index 7c69c119..88268d64 100644 --- a/ui/src/components/health-check-item.tsx +++ b/ui/src/components/health-check-item.tsx @@ -1,170 +1,144 @@ -import { - CheckCircle2, - AlertTriangle, - XCircle, - Info, - Wrench, - ChevronDown, - Terminal, -} from 'lucide-react'; +import { ChevronRight, Copy, Terminal, Wrench } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useFixHealth, type HealthCheck } from '@/hooks/use-health'; import { cn } from '@/lib/utils'; import { useState } from 'react'; +import { toast } from 'sonner'; const statusConfig = { - ok: { - icon: CheckCircle2, - color: 'text-green-600', - bg: 'bg-green-500/5', - border: 'border-green-500/20', - label: '[OK]', - }, - warning: { - icon: AlertTriangle, - color: 'text-yellow-500', - bg: 'bg-yellow-500/5', - border: 'border-yellow-500/20', - label: '[!]', - }, - error: { - icon: XCircle, - color: 'text-red-500', - bg: 'bg-red-500/5', - border: 'border-red-500/20', - label: '[X]', - }, - info: { - icon: Info, - color: 'text-blue-500', - bg: 'bg-blue-500/5', - border: 'border-blue-500/20', - label: '[i]', - }, + ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' }, + warning: { dot: 'bg-yellow-500', label: 'WARN', labelColor: 'text-yellow-500' }, + error: { dot: 'bg-red-500', label: 'ERR', labelColor: 'text-red-500' }, + info: { dot: 'bg-blue-500', label: 'INFO', labelColor: 'text-blue-500' }, }; export function HealthCheckItem({ check }: { check: HealthCheck }) { const fixMutation = useFixHealth(); const config = statusConfig[check.status]; - const Icon = config.icon; const [isOpen, setIsOpen] = useState(false); - const hasExpandableContent = check.details || check.fix; + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + }; + + // Compact single-line display for items without expandable content if (!hasExpandableContent) { return (
-
+
+ {check.status !== 'ok' && ( +
)} - > - -
-
-
-

{check.name}

- - {config.label} - -
-

{check.message}

+ + {/* Check name */} + {check.name} + + {/* Status label */} + + [{config.label}] + + + {/* Fix button for fixable non-ok items */} {check.fixable && check.status !== 'ok' && ( )}
); } + // Expandable display for items with details or fix commands return (
- -
+
+ {/* Message */} +

{check.message}

+ + {/* Details block */} {check.details && ( -
-

- {check.details} -

-
+
+                {check.details}
+              
)} + {/* Fix command block */} {check.fix && ( -
-
- - {check.fix} +
+
+ + {check.fix} +
{check.fixable && check.status !== 'ok' && ( @@ -172,9 +146,9 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) { size="sm" onClick={() => fixMutation.mutate(check.id)} disabled={fixMutation.isPending} - className="h-auto py-3 px-6 shadow-sm shrink-0" + className="h-7 px-3 text-xs" > - + Apply Fix )} diff --git a/ui/src/components/health-gauge.tsx b/ui/src/components/health-gauge.tsx new file mode 100644 index 00000000..2936b726 --- /dev/null +++ b/ui/src/components/health-gauge.tsx @@ -0,0 +1,91 @@ +import { cn } from '@/lib/utils'; + +interface HealthGaugeProps { + passed: number; + total: number; + status: 'ok' | 'warning' | 'error'; + size?: 'sm' | 'md' | 'lg'; +} + +const sizeConfig = { + sm: { dimension: 80, strokeWidth: 6, fontSize: 'text-lg', labelSize: 'text-[10px]' }, + md: { dimension: 120, strokeWidth: 8, fontSize: 'text-3xl', labelSize: 'text-xs' }, + lg: { dimension: 160, strokeWidth: 10, fontSize: 'text-4xl', labelSize: 'text-sm' }, +}; + +const statusColors = { + ok: { stroke: '#22C55E', glow: 'rgba(34, 197, 94, 0.4)' }, + warning: { stroke: '#EAB308', glow: 'rgba(234, 179, 8, 0.4)' }, + error: { stroke: '#EF4444', glow: 'rgba(239, 68, 68, 0.4)' }, +}; + +export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) { + const config = sizeConfig[size]; + const colors = statusColors[status]; + const percentage = total > 0 ? Math.round((passed / total) * 100) : 0; + + const radius = (config.dimension - config.strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference - (percentage / 100) * circumference; + const center = config.dimension / 2; + + return ( +
+ + {/* Background track */} + + {/* Progress arc */} + + {/* Animated glow dot at end of arc */} + {percentage > 0 && ( + + )} + + {/* Center content */} +
+ + {percentage} + + + health + +
+
+ ); +} diff --git a/ui/src/components/health-group-section.tsx b/ui/src/components/health-group-section.tsx new file mode 100644 index 00000000..9b0e4d30 --- /dev/null +++ b/ui/src/components/health-group-section.tsx @@ -0,0 +1,111 @@ +import { ChevronDown, Monitor, Settings, Users, Shield, Zap } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { HealthCheckItem } from '@/components/health-check-item'; +import { type HealthGroup } from '@/hooks/use-health'; +import { cn } from '@/lib/utils'; +import { useState } from 'react'; + +const groupIcons: Record = { + Monitor, + Settings, + Users, + Shield, + Zap, +}; + +interface HealthGroupSectionProps { + group: HealthGroup; + defaultOpen?: boolean; +} + +export function HealthGroupSection({ group, defaultOpen = true }: HealthGroupSectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const Icon = groupIcons[group.icon] || Monitor; + + const passed = group.checks.filter((c) => c.status === 'ok').length; + const total = group.checks.length; + const hasErrors = group.checks.some((c) => c.status === 'error'); + const hasWarnings = group.checks.some((c) => c.status === 'warning'); + const percentage = Math.round((passed / total) * 100); + + // Determine status color + const statusColor = hasErrors + ? 'text-red-500' + : hasWarnings + ? 'text-yellow-500' + : 'text-green-500'; + const progressColor = hasErrors ? 'bg-red-500' : hasWarnings ? 'bg-yellow-500' : 'bg-green-500'; + + return ( + +
+ {/* Group header */} + + + + + {/* Checks list */} + +
+ {group.checks.map((check) => ( + + ))} +
+
+
+
+ ); +} diff --git a/ui/src/components/health-stats-bar.tsx b/ui/src/components/health-stats-bar.tsx new file mode 100644 index 00000000..042a3f89 --- /dev/null +++ b/ui/src/components/health-stats-bar.tsx @@ -0,0 +1,85 @@ +import { cn } from '@/lib/utils'; + +interface HealthStatsBarProps { + total: number; + passed: number; + warnings: number; + errors: number; + info: number; +} + +interface StatItemProps { + label: string; + value: number; + color: string; + bgColor: string; +} + +function StatItem({ label, value, color, bgColor }: StatItemProps) { + return ( +
+
+ + {label} + + {value} +
+ ); +} + +export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) { + // Calculate percentages for the progress bar + const passedPct = (passed / total) * 100; + const warningPct = (warnings / total) * 100; + const errorPct = (errors / total) * 100; + const infoPct = (info / total) * 100; + + return ( +
+ {/* Progress bar visualization */} +
+ {errorPct > 0 && ( +
+ )} + {warningPct > 0 && ( +
+ )} + {infoPct > 0 && ( +
+ )} + {passedPct > 0 && ( +
+ )} +
+ + {/* Stats row */} +
+
+ + Checks + + {total} +
+ +
+ + + + +
+
+
+ ); +} diff --git a/ui/src/index.css b/ui/src/index.css index 62db957d..1cdd21ed 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -120,11 +120,12 @@ } body { @apply bg-background text-foreground; - font-family: 'Fira Sans', system-ui, sans-serif; + font-family: 'IBM Plex Sans', 'Fira Sans', system-ui, sans-serif; margin: 0; } code, - pre { - font-family: 'Fira Code', monospace; + pre, + .font-mono { + font-family: 'JetBrains Mono', 'Fira Code', monospace; } } diff --git a/ui/src/pages/health.tsx b/ui/src/pages/health.tsx index 2e3bcd31..7c8b5d94 100644 --- a/ui/src/pages/health.tsx +++ b/ui/src/pages/health.tsx @@ -1,58 +1,14 @@ import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; -import { - RefreshCw, - CheckCircle2, - AlertTriangle, - XCircle, - Info, - Monitor, - Settings, - Users, - Shield, - Zap, - Stethoscope, - Copy, - Terminal, -} from 'lucide-react'; -import { HealthCheckItem } from '@/components/health-check-item'; +import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react'; +import { HealthGauge } from '@/components/health-gauge'; +import { HealthStatsBar } from '@/components/health-stats-bar'; +import { HealthGroupSection } from '@/components/health-group-section'; import { useHealth, type HealthGroup } from '@/hooks/use-health'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; - -const groupIcons: Record = { - Monitor, - Settings, - Users, - Shield, - Zap, -}; - -const statusConfig = { - ok: { - icon: CheckCircle2, - label: 'All Systems Operational', - color: 'text-green-600', - bg: 'bg-green-500/10', - border: 'border-green-500/20', - }, - warning: { - icon: AlertTriangle, - label: 'Some Issues Detected', - color: 'text-yellow-500', - bg: 'bg-yellow-500/10', - border: 'border-yellow-500/20', - }, - error: { - icon: XCircle, - label: 'Action Required', - color: 'text-red-500', - bg: 'bg-red-500/10', - border: 'border-red-500/20', - }, -}; +import { useEffect, useState } from 'react'; function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) { if (summary.errors > 0) return 'error'; @@ -60,121 +16,60 @@ function getOverallStatus(summary: { passed: number; warnings: number; errors: n return 'ok'; } -function HealthGroupSection({ group }: { group: HealthGroup }) { - const Icon = groupIcons[group.icon] || Monitor; - - const groupPassed = group.checks.filter((c) => c.status === 'ok').length; - const groupTotal = group.checks.length; - const hasIssues = group.checks.some((c) => c.status === 'error' || c.status === 'warning'); - - return ( - - -
- -
- -
- {group.name} -
- - {groupPassed}/{groupTotal} - -
-
- -
- {group.checks.map((check) => ( - - ))} -
-
-
- ); +function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; } -function SummaryCard({ - label, - value, - icon: Icon, - color, -}: { - label: string; - value: number; - icon: typeof CheckCircle2; - color: string; -}) { +function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] { + return [...groups].sort((a, b) => { + const aErrors = a.checks.filter((c) => c.status === 'error').length; + const bErrors = b.checks.filter((c) => c.status === 'error').length; + const aWarnings = a.checks.filter((c) => c.status === 'warning').length; + const bWarnings = b.checks.filter((c) => c.status === 'warning').length; + if (aErrors !== bErrors) return bErrors - aErrors; + return bWarnings - aWarnings; + }); +} + +function TerminalHeader() { return ( - - -
-
- -
-
-

{value}

-

{label}

-
-
-
-
+
+ $ + ccs doctor +
); } function LoadingSkeleton() { return (
- {/* Hero Skeleton */} -
-
- -
- - + {/* Hero skeleton */} +
+
+ +
+ + +
-
- {/* Summary Skeleton */} -
+ {/* Stats skeleton */} + + + {/* Groups skeleton */} +
{[1, 2, 3, 4].map((i) => ( ))}
- - {/* Groups Skeleton */} -
- {[1, 2, 3, 4].map((i) => ( -
- - - - - -
- {[1, 2, 3].map((j) => ( - - ))} -
-
-
-
- ))} -
); } @@ -182,33 +77,53 @@ function LoadingSkeleton() { export function HealthPage() { const { data, isLoading, refetch, dataUpdatedAt } = useHealth(); - const formatTime = (timestamp: number) => { - return new Date(timestamp).toLocaleTimeString(); - }; + // Use dataUpdatedAt directly instead of storing in state + const lastRefresh = dataUpdatedAt; + + // Update relative time display by forcing re-render every second + const [tick, setTick] = useState(0); + useEffect(() => { + const interval = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(interval); + }, []); + // Consume tick to prevent unused variable warning + void tick; const copyDoctorCommand = () => { navigator.clipboard.writeText('ccs doctor'); toast.success('Copied to clipboard'); }; + const handleRefresh = () => { + refetch(); + toast.info('Refreshing health checks...'); + }; + if (isLoading && !data) { return ; } const overallStatus = data ? getOverallStatus(data.summary) : 'ok'; - const status = statusConfig[overallStatus]; - const StatusIcon = status.icon; + const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : []; return (
- {/* Hero Section */} + {/* Hero Section - Terminal-inspired control center header */}
- {/* Subtle background pattern */} + {/* Subtle scan lines effect */} +
+ + {/* Grid pattern background */}
-
- {/* Left: Title and Status */} -
-
- +
+ {/* Left: Health Gauge - excludes info from percentage */} + {data && ( +
+
-
-
-

Health Check

- {data?.version && ( - - v{data.version} - - )} -
-
- - {status.label} -
+ )} + + {/* Center: Title and status */} +
+ {/* Terminal prompt */} + + + {/* Main title */} +
+

System Health

+ {data?.version && ( + + build {data.version} + + )} +
+ + {/* Status message */} +
+ + Last scan: + + {lastRefresh ? formatRelativeTime(lastRefresh) : '--'} + + | + Auto-refresh: + 30s
{/* Right: Actions */} -
+
-
- - {/* Last check time */} - {dataUpdatedAt && ( -

- Last check: {formatTime(dataUpdatedAt)} -

- )}
- {/* Summary Stats */} + {/* Stats Bar */} {data && ( -
- + - - -
)} - {/* Health Check Groups */} - {data?.groups && ( -
- {data.groups.map((group) => ( -
- -
+ {/* Health Check Groups - Single column layout */} + {sortedGroups.length > 0 && ( +
+ {sortedGroups.map((group, index) => ( + c.status === 'error' || c.status === 'warning') + } + /> ))}
)} - {/* Issues Summary */} - {data && (data.summary.errors > 0 || data.summary.warnings > 0) && ( - - - - - Issues Detected - - - -
- {data.checks - .filter((c) => c.status === 'error' || c.status === 'warning') - .map((check) => ( -
- {check.status === 'error' ? ( - - ) : ( - - )} -
-

{check.name}

-

{check.message}

- {check.fix && ( - - {check.fix} - - )} -
-
- ))} -
-
-
- )} + {/* Footer metadata */} +
+
+ + Version {data?.version ?? '--'} + + + Platform{' '} + + {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} + + +
+
+
+ Live monitoring active +
+
); }