mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
feat(ui): redesign health page with prioritized layout and premium aesthetics
This commit is contained in:
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<h2 className="text-sm font-bold tracking-widest uppercase text-muted-foreground/60">
|
||||
{t('health.environmentAudit')}
|
||||
</h2>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-500/60" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<AuditGroup key={group.id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="group/audit">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border transition-all duration-300',
|
||||
isOpen ? 'bg-muted/30 border-border/60' : 'bg-background hover:border-border/60'
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="w-full flex items-center gap-4 px-5 py-4 text-left">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full transition-colors',
|
||||
hasIssues ? 'bg-rose-500/10 text-rose-500' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{hasIssues ? <Info className="w-4 h-4" /> : <ShieldCheck className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold tracking-tight">{group.name}</h3>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-mono">
|
||||
{group.checks.length} Checks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasIssues && (
|
||||
<div className="px-2 py-0.5 rounded-full bg-rose-500/10 text-rose-500 text-[10px] font-bold font-mono">
|
||||
{issuesCount}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 text-muted-foreground transition-transform duration-300',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="px-5 pb-5 space-y-1">
|
||||
{group.checks.map((check) => (
|
||||
<HealthCheckItem key={check.id} check={check} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-[2rem] p-px transition-all duration-300',
|
||||
isError
|
||||
? 'bg-gradient-to-br from-rose-500/20 via-rose-500/5 to-transparent'
|
||||
: 'bg-gradient-to-br from-amber-500/20 via-amber-500/5 to-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="relative h-full rounded-[calc(2rem-1px)] bg-background/80 backdrop-blur-md overflow-hidden">
|
||||
{/* Double-Bezel Inner Highlight */}
|
||||
<div className="absolute inset-0 rounded-[calc(2rem-1px)] shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)] pointer-events-none" />
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Status Icon with Ring */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-12 h-12 rounded-2xl shrink-0',
|
||||
isError ? 'bg-rose-500/10 text-rose-500' : 'bg-amber-500/10 text-amber-500'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold tracking-tight">{check.name}</h3>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-muted rounded-full transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-5 h-5 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm font-medium leading-relaxed">{check.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 pt-4 border-t border-border/40 space-y-4">
|
||||
{check.details && (
|
||||
<div className="rounded-xl bg-muted/30 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground break-all">
|
||||
{check.details}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(check.fix || check.fixable) && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
{check.fix && (
|
||||
<div className="flex-1 flex items-center gap-2 h-10 px-3 rounded-full bg-background/50 border border-border/40 group/fix">
|
||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<code className="text-xs font-mono flex-1 truncate">{check.fix}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyFix}
|
||||
className="h-6 w-6 rounded-full opacity-0 group-hover/fix:opacity-100 transition-opacity"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{check.fixable && (
|
||||
<Button
|
||||
onClick={() => fixMutation.mutate(check.id)}
|
||||
disabled={fixMutation.isPending}
|
||||
className={cn(
|
||||
'h-10 px-6 rounded-full font-bold shadow-lg shadow-primary/20 transition-all active:scale-95',
|
||||
isError ? 'bg-rose-500 hover:bg-rose-600' : 'bg-amber-500 hover:bg-amber-600'
|
||||
)}
|
||||
>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
{t('health.applyFix')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<h2 className="text-sm font-bold tracking-widest uppercase text-muted-foreground/60">
|
||||
{t('health.attentionRequired')}
|
||||
</h2>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<span className="font-mono text-xs text-rose-500 font-bold">{checks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{checks.map((check) => (
|
||||
<HealthPriorityCard key={check.id} check={check} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-2xl border transition-all duration-500',
|
||||
hasIssues
|
||||
? 'bg-rose-500/5 border-rose-500/20 shadow-[0_0_20px_-12px_rgba(244,63,94,0.3)]'
|
||||
: 'bg-emerald-500/5 border-emerald-500/20 shadow-[0_0_20px_-12px_rgba(16,185,129,0.3)]'
|
||||
)}
|
||||
>
|
||||
{/* Animated Mesh Gradient Background */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 opacity-[0.05] blur-3xl pointer-events-none transition-colors duration-1000',
|
||||
hasIssues ? 'bg-rose-500' : 'bg-emerald-500'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col md:flex-row items-center gap-4 px-6 py-4">
|
||||
{/* Status Indicator */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
hasIssues ? 'bg-rose-500' : 'bg-emerald-500'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-full animate-ping opacity-40',
|
||||
hasIssues ? 'bg-rose-500' : 'bg-emerald-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="font-mono text-sm font-bold tracking-tight uppercase">
|
||||
{hasIssues ? t('health.issuesDetected') : t('health.systemOptimal')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block h-4 w-px bg-border/50" />
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="flex items-center gap-6 text-xs font-mono">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground uppercase text-[10px] tracking-widest">
|
||||
Checks
|
||||
</span>
|
||||
<span>{summary.total}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground uppercase text-[10px] tracking-widest">
|
||||
Passed
|
||||
</span>
|
||||
<span className="text-emerald-500">{summary.passed}</span>
|
||||
</div>
|
||||
{summary.warnings > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground uppercase text-[10px] tracking-widest">
|
||||
Warnings
|
||||
</span>
|
||||
<span className="text-amber-500 font-bold">{summary.warnings}</span>
|
||||
</div>
|
||||
)}
|
||||
{summary.errors > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground uppercase text-[10px] tracking-widest">
|
||||
Errors
|
||||
</span>
|
||||
<span className="text-rose-500 font-bold underline decoration-rose-500/30">
|
||||
{summary.errors}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Meta & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-end text-[10px] font-mono text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RefreshCw className={cn('w-2.5 h-2.5', isLoading && 'animate-spin')} />
|
||||
<span>{lastScan ? formatRelativeTime(lastScan) : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 opacity-60">
|
||||
<Cpu className="w-2.5 h-2.5" />
|
||||
<span>{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyDoctorCommand}
|
||||
className="h-8 px-3 gap-2 font-mono text-[10px] bg-background/40 hover:bg-background/60 border border-border/40 rounded-full"
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">ccs doctor</span>
|
||||
<Copy className="w-3 h-3 opacity-40" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 rounded-full bg-background/40 hover:bg-background/60 border border-border/40"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+95
-226
@@ -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, unknown>) => 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 (
|
||||
<div className="font-mono text-sm text-muted-foreground flex items-center gap-2">
|
||||
<span className="text-green-500">$</span>
|
||||
<span>ccs doctor</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
{/* Hero skeleton */}
|
||||
<div className="rounded-xl border bg-gradient-to-br from-background to-muted/20 p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="w-[120px] h-[120px] rounded-full" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-12">
|
||||
{/* Ribbon skeleton */}
|
||||
<Skeleton className="h-20 w-full rounded-2xl" />
|
||||
|
||||
<div className="space-y-16">
|
||||
{/* Priority skeleton */}
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-40 rounded-[2rem]" />
|
||||
<Skeleton className="h-40 rounded-[2rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats skeleton */}
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
|
||||
{/* Groups skeleton */}
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-lg" />
|
||||
))}
|
||||
{/* Audit skeleton */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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 <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
{/* Hero Section - Terminal-inspired control center header */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl border p-6',
|
||||
'bg-gradient-to-br from-background via-background to-muted/30'
|
||||
)}
|
||||
>
|
||||
{/* Subtle scan lines effect */}
|
||||
<div className="relative min-h-[100dvh] pb-20">
|
||||
{/* Dynamic Background */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.02] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 2px, currentColor 2px, currentColor 3px)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Grid pattern background */}
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
{/* Left: Health Gauge - excludes info from percentage */}
|
||||
{data && (
|
||||
<div className="shrink-0">
|
||||
<HealthGauge
|
||||
passed={data.summary.passed}
|
||||
total={data.summary.total - data.summary.info}
|
||||
status={overallStatus}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
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 */}
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Terminal prompt */}
|
||||
<TerminalHeader />
|
||||
|
||||
{/* Main title */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold font-mono tracking-tight">
|
||||
{t('health.systemHealth')}
|
||||
</h1>
|
||||
{data?.version && (
|
||||
<Badge variant="outline" className="font-mono text-xs bg-muted/50">
|
||||
{t('health.build', { version: data.version })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{t('health.lastScan')}</span>
|
||||
<span className="font-mono">
|
||||
{lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{t('health.autoRefresh')}</span>
|
||||
<span className="font-mono text-green-500">30s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyDoctorCommand}
|
||||
className="gap-2 font-mono text-xs"
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
ccs doctor
|
||||
<Copy className="w-3 h-3 opacity-50" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
<span className="hidden sm:inline">{t('health.refresh')}</span>
|
||||
<kbd className="hidden md:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
R
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -bottom-[20%] -right-[10%] w-[60%] h-[60%] blur-[120px] rounded-full opacity-[0.05] transition-colors duration-1000',
|
||||
hasIssues ? 'bg-amber-500' : 'bg-blue-500'
|
||||
)}
|
||||
/>
|
||||
{/* Grain overlay */}
|
||||
<div className="absolute inset-0 opacity-[0.02] mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{data && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<HealthStatsBar
|
||||
total={data.summary.total}
|
||||
passed={data.summary.passed}
|
||||
warnings={data.summary.warnings}
|
||||
errors={data.summary.errors}
|
||||
info={data.summary.info}
|
||||
<div className="relative p-6 max-w-6xl mx-auto space-y-12">
|
||||
{/* Status Ribbon */}
|
||||
{data && (
|
||||
<HealthStatusRibbon
|
||||
summary={data.summary}
|
||||
version={data.version}
|
||||
lastScan={dataUpdatedAt}
|
||||
isLoading={isLoading}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Health Check Groups - Single column layout */}
|
||||
{sortedGroups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{sortedGroups.map((group, index) => (
|
||||
<HealthGroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
defaultOpen={
|
||||
index < 2 ||
|
||||
group.checks.some((c) => c.status === 'error' || c.status === 'warning')
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-16">
|
||||
{/* Priority Issues */}
|
||||
{hasIssues ? (
|
||||
<HealthPriorityList checks={priorityChecks} />
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="py-20 flex flex-col items-center text-center space-y-6"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-[2.5rem] bg-emerald-500/10 flex items-center justify-center">
|
||||
<ShieldCheck className="w-12 h-12 text-emerald-500" />
|
||||
</div>
|
||||
<div className="absolute inset-0 w-24 h-24 rounded-[2.5rem] bg-emerald-500/20 animate-ping opacity-20" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">{t('health.allSystemsClear')}</h2>
|
||||
<p className="text-muted-foreground max-w-[40ch] mx-auto">
|
||||
{t('health.optimalStateDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Footer metadata */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
{t('health.version')} <span className="font-mono">{data?.version ?? '--'}</span>
|
||||
</span>
|
||||
<span>
|
||||
{t('health.platform')}{' '}
|
||||
<span className="font-mono">
|
||||
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
|
||||
</span>
|
||||
</span>
|
||||
{/* Detailed Audit */}
|
||||
{data?.groups && <HealthAuditSection groups={data.groups} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span>{t('health.liveMonitoring')}</span>
|
||||
|
||||
{/* Footer metadata */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-[10px] uppercase tracking-widest font-mono text-muted-foreground/60 border-t border-border/40 pt-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="opacity-50">Build</span>
|
||||
<span className="text-foreground">{data?.version ?? '--'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="opacity-50">Platform</span>
|
||||
<span className="text-foreground">
|
||||
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-muted/30 border border-border/40">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>{t('health.liveMonitoring')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user