mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 14:15:56 +00:00
feat(ui): redesign health page with prioritized layout and premium aesthetics
This commit is contained in:
1 parent
b868852e63
commit
36ed091e4d
6 files changed
+516
-226
No files matched your search
@@ -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 { HealthGauge } from './health-gauge';
|
||||||
export { HealthGroupSection } from './health-group-section';
|
export { HealthGroupSection } from './health-group-section';
|
||||||
export { HealthStatsBar } from './health-stats-bar';
|
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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react';
|
import { ShieldCheck } from 'lucide-react';
|
||||||
import { HealthGauge } from '@/components/health/health-gauge';
|
import { HealthStatusRibbon, HealthPriorityList, HealthAuditSection } from '@/components/health';
|
||||||
import { HealthStatsBar } from '@/components/health/health-stats-bar';
|
import { useHealth } from '@/hooks/use-health';
|
||||||
import { HealthGroupSection } from '@/components/health/health-group-section';
|
|
||||||
import { useHealth, type HealthGroup } from '@/hooks/use-health';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { motion } from 'framer-motion';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
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() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
<div className="p-6 max-w-6xl mx-auto space-y-12">
|
||||||
{/* Hero skeleton */}
|
{/* Ribbon skeleton */}
|
||||||
<div className="rounded-xl border bg-gradient-to-br from-background to-muted/20 p-6">
|
<Skeleton className="h-20 w-full rounded-2xl" />
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<Skeleton className="w-[120px] h-[120px] rounded-full" />
|
<div className="space-y-16">
|
||||||
<div className="flex-1 space-y-3">
|
{/* Priority skeleton */}
|
||||||
<Skeleton className="h-5 w-48" />
|
<div className="space-y-6">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-4 w-48" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats skeleton */}
|
{/* Audit skeleton */}
|
||||||
<Skeleton className="h-16 w-full rounded-lg" />
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
{/* Groups skeleton */}
|
{[1, 2, 3].map((i) => (
|
||||||
<div className="space-y-3">
|
<Skeleton key={i} className="h-16 rounded-2xl" />
|
||||||
{[1, 2, 3, 4].map((i) => (
|
))}
|
||||||
<Skeleton key={i} className="h-20 rounded-lg" />
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -82,180 +38,93 @@ export function HealthPage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
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) {
|
if (isLoading && !data) {
|
||||||
return <LoadingSkeleton />;
|
return <LoadingSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overallStatus = data ? getOverallStatus(data.summary) : 'ok';
|
const priorityChecks =
|
||||||
const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : [];
|
data?.checks.filter((c) => c.status === 'error' || c.status === 'warning') ?? [];
|
||||||
|
const hasIssues = priorityChecks.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
<div className="relative min-h-[100dvh] pb-20">
|
||||||
{/* Hero Section - Terminal-inspired control center header */}
|
{/* Dynamic Background */}
|
||||||
<div
|
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||||
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
|
<div
|
||||||
className="absolute inset-0 opacity-[0.02] pointer-events-none"
|
className={cn(
|
||||||
style={{
|
'absolute -top-[20%] -left-[10%] w-[70%] h-[70%] blur-[120px] rounded-full opacity-[0.08] transition-colors duration-1000',
|
||||||
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 2px, currentColor 2px, currentColor 3px)`,
|
hasIssues ? 'bg-rose-500' : 'bg-emerald-500'
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
{/* Center: Title and status */}
|
<div
|
||||||
<div className="flex-1 space-y-3">
|
className={cn(
|
||||||
{/* Terminal prompt */}
|
'absolute -bottom-[20%] -right-[10%] w-[60%] h-[60%] blur-[120px] rounded-full opacity-[0.05] transition-colors duration-1000',
|
||||||
<TerminalHeader />
|
hasIssues ? 'bg-amber-500' : 'bg-blue-500'
|
||||||
|
)}
|
||||||
{/* Main title */}
|
/>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
{/* Grain overlay */}
|
||||||
<h1 className="text-2xl font-bold font-mono tracking-tight">
|
<div className="absolute inset-0 opacity-[0.02] mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
{/* Stats Bar */}
|
<div className="relative p-6 max-w-6xl mx-auto space-y-12">
|
||||||
{data && (
|
{/* Status Ribbon */}
|
||||||
<div className="rounded-lg border bg-card p-4">
|
{data && (
|
||||||
<HealthStatsBar
|
<HealthStatusRibbon
|
||||||
total={data.summary.total}
|
summary={data.summary}
|
||||||
passed={data.summary.passed}
|
version={data.version}
|
||||||
warnings={data.summary.warnings}
|
lastScan={dataUpdatedAt}
|
||||||
errors={data.summary.errors}
|
isLoading={isLoading}
|
||||||
info={data.summary.info}
|
onRefresh={refetch}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Health Check Groups - Single column layout */}
|
<div className="space-y-16">
|
||||||
{sortedGroups.length > 0 && (
|
{/* Priority Issues */}
|
||||||
<div className="space-y-3">
|
{hasIssues ? (
|
||||||
{sortedGroups.map((group, index) => (
|
<HealthPriorityList checks={priorityChecks} />
|
||||||
<HealthGroupSection
|
) : (
|
||||||
key={group.id}
|
<motion.div
|
||||||
group={group}
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
defaultOpen={
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
index < 2 ||
|
className="py-20 flex flex-col items-center text-center space-y-6"
|
||||||
group.checks.some((c) => c.status === 'error' || c.status === 'warning')
|
>
|
||||||
}
|
<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>
|
||||||
)}
|
<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 */}
|
{/* Detailed Audit */}
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
|
{data?.groups && <HealthAuditSection groups={data.groups} />}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
{/* Footer metadata */}
|
||||||
<span>{t('health.liveMonitoring')}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user