diff --git a/ui/src/components/health/health-audit-section.tsx b/ui/src/components/health/health-audit-section.tsx new file mode 100644 index 00000000..bcd08a1e --- /dev/null +++ b/ui/src/components/health/health-audit-section.tsx @@ -0,0 +1,91 @@ +import { type HealthGroup } from '@/hooks/use-health'; +import { HealthCheckItem } from './health-check-item'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ChevronRight, ShieldCheck, Info } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; + +interface HealthAuditSectionProps { + groups: HealthGroup[]; +} + +export function HealthAuditSection({ groups }: HealthAuditSectionProps) { + const { t } = useTranslation(); + + return ( +
+
+

+ {t('health.environmentAudit')} +

+
+ +
+ +
+ {groups.map((group) => ( + + ))} +
+
+ ); +} + +function AuditGroup({ group }: { group: HealthGroup }) { + const [isOpen, setIsOpen] = useState(false); + const issuesCount = group.checks.filter((c) => c.status === 'error' || c.status === 'warning').length; + const hasIssues = issuesCount > 0; + + return ( + +
+ + + + + +
+ {group.checks.map((check) => ( + + ))} +
+
+
+
+ ); +} diff --git a/ui/src/components/health/health-priority-card.tsx b/ui/src/components/health/health-priority-card.tsx new file mode 100644 index 00000000..d397807a --- /dev/null +++ b/ui/src/components/health/health-priority-card.tsx @@ -0,0 +1,133 @@ +import { AlertCircle, AlertTriangle, Copy, Terminal, Wrench, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useFixHealth, type HealthCheck } from '@/hooks/use-health'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; + +interface HealthPriorityCardProps { + check: HealthCheck; +} + +export function HealthPriorityCard({ check }: HealthPriorityCardProps) { + const { t } = useTranslation(); + const fixMutation = useFixHealth(); + const [isExpanded, setIsExpanded] = useState(true); + + const isError = check.status === 'error'; + const Icon = isError ? AlertCircle : AlertTriangle; + + const copyFix = () => { + if (check.fix) { + navigator.clipboard.writeText(check.fix); + toast.success(t('health.copied')); + } + }; + + return ( + +
+ {/* Double-Bezel Inner Highlight */} +
+ +
+
+ {/* Status Icon with Ring */} +
+ +
+ +
+
+

{check.name}

+ +
+

{check.message}

+
+
+ + + {isExpanded && ( + +
+ {check.details && ( +
+ {check.details} +
+ )} + + {(check.fix || check.fixable) && ( +
+ {check.fix && ( +
+ + {check.fix} + +
+ )} + + {check.fixable && ( + + )} +
+ )} +
+
+ )} +
+
+
+ + ); +} diff --git a/ui/src/components/health/health-priority-list.tsx b/ui/src/components/health/health-priority-list.tsx new file mode 100644 index 00000000..f3445933 --- /dev/null +++ b/ui/src/components/health/health-priority-list.tsx @@ -0,0 +1,34 @@ +import { HealthPriorityCard } from './health-priority-card'; +import { type HealthCheck } from '@/hooks/use-health'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; + +interface HealthPriorityListProps { + checks: HealthCheck[]; +} + +export function HealthPriorityList({ checks }: HealthPriorityListProps) { + const { t } = useTranslation(); + + if (checks.length === 0) return null; + + return ( +
+
+

+ {t('health.attentionRequired')} +

+
+ {checks.length} +
+ +
+ + {checks.map((check) => ( + + ))} + +
+
+ ); +} diff --git a/ui/src/components/health/health-status-ribbon.tsx b/ui/src/components/health/health-status-ribbon.tsx new file mode 100644 index 00000000..418a92dd --- /dev/null +++ b/ui/src/components/health/health-status-ribbon.tsx @@ -0,0 +1,160 @@ +import { Cpu, RefreshCw, Terminal, Copy } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +interface HealthStatusRibbonProps { + summary: { + passed: number; + warnings: number; + errors: number; + total: number; + info: number; + }; + version: string; + lastScan: number; + isLoading: boolean; + onRefresh: () => void; +} + +export function HealthStatusRibbon({ + summary, + version, + lastScan, + isLoading, + onRefresh, +}: HealthStatusRibbonProps) { + const { t } = useTranslation(); + + const formatRelativeTime = (timestamp: number) => { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 5) return t('health.justNow'); + if (seconds < 60) return t('health.secondsAgo', { count: seconds }); + return t('health.minutesAgo', { count: Math.floor(seconds / 60) }); + }; + + const copyDoctorCommand = () => { + navigator.clipboard.writeText('ccs doctor'); + toast.success(t('health.copied')); + }; + + const hasIssues = summary.errors > 0 || summary.warnings > 0; + + return ( +
+ {/* Animated Mesh Gradient Background */} +
+ +
+ {/* Status Indicator */} +
+
+
+
+
+

+ {hasIssues ? t('health.issuesDetected') : t('health.systemOptimal')} +

+
+ +
+ + {/* Stats Summary */} +
+
+ + Checks + + {summary.total} +
+
+ + Passed + + {summary.passed} +
+ {summary.warnings > 0 && ( +
+ + Warnings + + {summary.warnings} +
+ )} + {summary.errors > 0 && ( +
+ + Errors + + + {summary.errors} + +
+ )} +
+ +
+ + {/* Meta & Actions */} +
+
+
+ + {lastScan ? formatRelativeTime(lastScan) : '--'} +
+
+ + {version} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/health/index.ts b/ui/src/components/health/index.ts index 16eac15e..205877de 100644 --- a/ui/src/components/health/index.ts +++ b/ui/src/components/health/index.ts @@ -7,3 +7,6 @@ export { HealthCheckItem } from './health-check-item'; export { HealthGauge } from './health-gauge'; export { HealthGroupSection } from './health-group-section'; export { HealthStatsBar } from './health-stats-bar'; +export { HealthStatusRibbon } from './health-status-ribbon'; +export { HealthPriorityList } from './health-priority-list'; +export { HealthAuditSection } from './health-audit-section'; diff --git a/ui/src/pages/health.tsx b/ui/src/pages/health.tsx index 5239fd5c..97bf46ab 100644 --- a/ui/src/pages/health.tsx +++ b/ui/src/pages/health.tsx @@ -1,78 +1,34 @@ -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; -import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react'; -import { HealthGauge } from '@/components/health/health-gauge'; -import { HealthStatsBar } from '@/components/health/health-stats-bar'; -import { HealthGroupSection } from '@/components/health/health-group-section'; -import { useHealth, type HealthGroup } from '@/hooks/use-health'; +import { ShieldCheck } from 'lucide-react'; +import { HealthStatusRibbon, HealthPriorityList, HealthAuditSection } from '@/components/health'; +import { useHealth } from '@/hooks/use-health'; import { cn } from '@/lib/utils'; -import { toast } from 'sonner'; -import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; import { useTranslation } from 'react-i18next'; -function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) { - if (summary.errors > 0) return 'error'; - if (summary.warnings > 0) return 'warning'; - return 'ok'; -} - -function formatRelativeTime( - timestamp: number, - t: (key: string, options?: Record) => string -): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 5) return t('health.justNow'); - if (seconds < 60) return t('health.secondsAgo', { count: seconds }); - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return t('health.minutesAgo', { count: minutes }); - const hours = Math.floor(minutes / 60); - return t('health.hoursAgo', { count: hours }); -} - -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 ( -
- $ - ccs doctor -
- ); -} - function LoadingSkeleton() { return ( -
- {/* Hero skeleton */} -
-
- -
- - - +
+ {/* Ribbon skeleton */} + + +
+ {/* Priority skeleton */} +
+ +
+ +
-
- {/* Stats skeleton */} - - - {/* Groups skeleton */} -
- {[1, 2, 3, 4].map((i) => ( - - ))} + {/* Audit skeleton */} +
+ + {[1, 2, 3].map((i) => ( + + ))} +
); @@ -82,180 +38,93 @@ export function HealthPage() { const { t } = useTranslation(); const { data, isLoading, refetch, dataUpdatedAt } = useHealth(); - // 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(t('health.copied')); - }; - - const handleRefresh = () => { - refetch(); - toast.info(t('health.refreshing')); - }; - if (isLoading && !data) { return ; } - const overallStatus = data ? getOverallStatus(data.summary) : 'ok'; - const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : []; + const priorityChecks = + data?.checks.filter((c) => c.status === 'error' || c.status === 'warning') ?? []; + const hasIssues = priorityChecks.length > 0; return ( -
- {/* Hero Section - Terminal-inspired control center header */} -
- {/* Subtle scan lines effect */} +
+ {/* Dynamic Background */} +
- - {/* Grid pattern background */} -
-
-
- -
- {/* Left: Health Gauge - excludes info from percentage */} - {data && ( -
- -
+ className={cn( + 'absolute -top-[20%] -left-[10%] w-[70%] h-[70%] blur-[120px] rounded-full opacity-[0.08] transition-colors duration-1000', + hasIssues ? 'bg-rose-500' : 'bg-emerald-500' )} - - {/* Center: Title and status */} -
- {/* Terminal prompt */} - - - {/* Main title */} -
-

- {t('health.systemHealth')} -

- {data?.version && ( - - {t('health.build', { version: data.version })} - - )} -
- - {/* Status message */} -
- - {t('health.lastScan')} - - {lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'} - - | - {t('health.autoRefresh')} - 30s -
-
- - {/* Right: Actions */} -
- - -
-
+ /> +
+ {/* Grain overlay */} +
- {/* Stats Bar */} - {data && ( -
- + {/* Status Ribbon */} + {data && ( + -
- )} + )} - {/* Health Check Groups - Single column layout */} - {sortedGroups.length > 0 && ( -
- {sortedGroups.map((group, index) => ( - c.status === 'error' || c.status === 'warning') - } - /> - ))} -
- )} +
+ {/* Priority Issues */} + {hasIssues ? ( + + ) : ( + +
+
+ +
+
+
+
+

{t('health.allSystemsClear')}

+

+ {t('health.optimalStateDesc')} +

+
+ + )} - {/* Footer metadata */} -
-
- - {t('health.version')} {data?.version ?? '--'} - - - {t('health.platform')}{' '} - - {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} - - + {/* Detailed Audit */} + {data?.groups && }
-
-
- {t('health.liveMonitoring')} + + {/* Footer metadata */} +
+
+
+ Build + {data?.version ?? '--'} +
+
+ Platform + + {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} + +
+
+
+
+ {t('health.liveMonitoring')} +