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..db4dfc8f --- /dev/null +++ b/ui/src/components/health/health-audit-section.tsx @@ -0,0 +1,93 @@ +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..e7ec29ac --- /dev/null +++ b/ui/src/components/health/health-priority-card.tsx @@ -0,0 +1,135 @@ +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..84836918 --- /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 { 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..41b07ce4 --- /dev/null +++ b/ui/src/components/health/health-status-ribbon.tsx @@ -0,0 +1,162 @@ +import { Cpu, RefreshCw, Terminal, Copy } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +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; +} + +function formatRelativeTime( + timestamp: number, + t: (key: string, options?: Record) => 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 }); + return t('health.minutesAgo', { count: Math.floor(seconds / 60) }); +} + +export function HealthStatusRibbon({ + summary, + version, + lastScan, + isLoading, + onRefresh, +}: HealthStatusRibbonProps) { + const { t } = useTranslation(); + + 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, t) : '--'} +
+
+ + {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 211de300..97bf46ab 100644 --- a/ui/src/pages/health.tsx +++ b/ui/src/pages/health.tsx @@ -1,271 +1,132 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Activity, - AlertTriangle, - CheckCircle2, - Copy, - Cpu, - Info, - RefreshCw, - Terminal, -} from 'lucide-react'; -import { toast } from 'sonner'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; -import { HealthGauge } from '@/components/health/health-gauge'; -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 { PageShell, PageHeader } from '@/components/page-shell'; -import { - MonitorLayout, - KpiRow, - KpiCard, - MonitorGrid, - MonitorCard, -} from '@/components/monitor-layout'; +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 LoadingSkeleton() { + return ( +
+ {/* Ribbon skeleton */} + -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 }); -} +
+ {/* Priority skeleton */} +
+ +
+ + +
+
-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; - }); + {/* Audit skeleton */} +
+ + {[1, 2, 3].map((i) => ( + + ))} +
+
+
+ ); } export function HealthPage() { const { t } = useTranslation(); const { data, isLoading, refetch, dataUpdatedAt } = useHealth(); - // Force re-render every second so the relative-time label stays fresh. - const [tick, setTick] = useState(0); - useEffect(() => { - const interval = setInterval(() => setTick((n) => n + 1), 1000); - return () => clearInterval(interval); - }, []); - 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 ; + 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 ( - - - {t('health.systemHealth')} - {data?.version && ( - - {t('health.build', { version: data.version })} - - )} - - } - description={ - - - {t('health.lastScan')} - {dataUpdatedAt ? formatRelativeTime(dataUpdatedAt, t) : '--'} - | - {t('health.autoRefresh')} - 30s - - } - actions={ - <> - - - - } - /> +
+ {/* Dynamic Background */} +
+
+
+ {/* Grain overlay */} +
+
- - } - /> - } - /> - 0 ? 'warning' : 'default'} - icon={} - /> - 0 ? 'negative' : 'default'} - icon={} - /> - - ) - } - > - - {/* Hero: overall health gauge with terminal aesthetic */} - {data && ( - - $ - ccs doctor - - } - meta={data.summary.info > 0 ? `${data.summary.info} info` : undefined} +
+ {/* Status Ribbon */} + {data && ( + + )} + +
+ {/* Priority Issues */} + {hasIssues ? ( + + ) : ( + -
- -
-

Status

-

- {overallStatus === 'ok' && 'All systems nominal'} - {overallStatus === 'warning' && 'Attention required'} - {overallStatus === 'error' && 'Errors detected'} -

-

- {data.summary.passed}/{data.summary.total - data.summary.info} non-informational - checks passing -

+
+
+
+
- +
+

{t('health.allSystemsClear')}

+

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

+
+ )} - {/* Each health group becomes its own MonitorCard */} - {sortedGroups.map((group, index) => ( - - c.status === 'error' || c.status === 'warning') - } - /> - - ))} - + {/* Detailed Audit */} + {data?.groups && } +
- {/* Footer */} -
-
- - {t('health.version')} {data?.version ?? '--'} - - - {t('health.platform')}{' '} - + {/* Footer metadata */} +
+
+
+ Build + {data?.version ?? '--'} +
+
+ Platform + {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} - +
-
-
+
+
{t('health.liveMonitoring')}
- - - ); -} - -function HealthLoadingSkeleton() { - return ( - - } /> - - {[1, 2, 3, 4].map((i) => ( - - ))} - - } - > - - - - - {[1, 2, 3].map((i) => ( - - - - ))} - - - +
+
); }