mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 14:15:56 +00:00
chore: resolve merge conflicts and integrate health page redesign
This commit is contained in:
commit
4d314ef2b7
6 files changed
+528
-240
No files matched your search
@@ -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 (
|
||||
<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,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 (
|
||||
<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 { 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,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, unknown>) => 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 (
|
||||
<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, t) : '--'}</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';
|
||||
+101
-240
@@ -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 (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-12">
|
||||
{/* Ribbon skeleton */}
|
||||
<Skeleton className="h-20 w-full rounded-2xl" />
|
||||
|
||||
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 });
|
||||
}
|
||||
<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>
|
||||
|
||||
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 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 <HealthLoadingSkeleton />;
|
||||
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 (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
{t('health.systemHealth')}
|
||||
{data?.version && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{t('health.build', { version: data.version })}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
|
||||
<Cpu className="size-3.5" />
|
||||
<span>{t('health.lastScan')}</span>
|
||||
<span>{dataUpdatedAt ? formatRelativeTime(dataUpdatedAt, t) : '--'}</span>
|
||||
<span className="text-muted-foreground/60">|</span>
|
||||
<span>{t('health.autoRefresh')}</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">30s</span>
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyDoctorCommand}
|
||||
className="gap-2 font-mono text-xs"
|
||||
>
|
||||
<Terminal className="size-3.5" />
|
||||
ccs doctor
|
||||
<Copy className="size-3 opacity-50" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('size-4', isLoading && 'animate-spin')} />
|
||||
<span className="hidden sm:inline">{t('health.refresh')}</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="relative min-h-[100dvh] pb-20">
|
||||
{/* Dynamic Background */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<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'
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<MonitorLayout
|
||||
kpis={
|
||||
data && (
|
||||
<KpiRow>
|
||||
<KpiCard
|
||||
label={t('health.checks') ?? 'Checks'}
|
||||
value={data.summary.total}
|
||||
hint={`${data.summary.passed} ${t('health.ok') ?? 'OK'}`}
|
||||
tone="default"
|
||||
icon={<Activity className="size-4" />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Passed"
|
||||
value={data.summary.passed}
|
||||
tone="positive"
|
||||
icon={<CheckCircle2 className="size-4" />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Warnings"
|
||||
value={data.summary.warnings}
|
||||
tone={data.summary.warnings > 0 ? 'warning' : 'default'}
|
||||
icon={<AlertTriangle className="size-4" />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Errors"
|
||||
value={data.summary.errors}
|
||||
tone={data.summary.errors > 0 ? 'negative' : 'default'}
|
||||
icon={<Info className="size-4" />}
|
||||
/>
|
||||
</KpiRow>
|
||||
)
|
||||
}
|
||||
>
|
||||
<MonitorGrid>
|
||||
{/* Hero: overall health gauge with terminal aesthetic */}
|
||||
{data && (
|
||||
<MonitorCard
|
||||
span={12}
|
||||
variant="terminal"
|
||||
title={
|
||||
<span className="flex items-center gap-2 font-mono">
|
||||
<span className="text-emerald-500">$</span>
|
||||
ccs doctor
|
||||
</span>
|
||||
}
|
||||
meta={data.summary.info > 0 ? `${data.summary.info} info` : undefined}
|
||||
<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 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="flex flex-col items-center gap-6 py-2 sm:flex-row sm:justify-center">
|
||||
<HealthGauge
|
||||
passed={data.summary.passed}
|
||||
total={data.summary.total - data.summary.info}
|
||||
status={overallStatus}
|
||||
size="md"
|
||||
/>
|
||||
<div className="text-center font-mono text-sm text-emerald-300 sm:text-left">
|
||||
<p className="text-xs uppercase tracking-wider opacity-70">Status</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{overallStatus === 'ok' && 'All systems nominal'}
|
||||
{overallStatus === 'warning' && 'Attention required'}
|
||||
{overallStatus === 'error' && 'Errors detected'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs opacity-70">
|
||||
{data.summary.passed}/{data.summary.total - data.summary.info} non-informational
|
||||
checks passing
|
||||
</p>
|
||||
<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>
|
||||
</MonitorCard>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Each health group becomes its own MonitorCard */}
|
||||
{sortedGroups.map((group, index) => (
|
||||
<MonitorCard key={group.id} span={12}>
|
||||
<HealthGroupSection
|
||||
group={group}
|
||||
defaultOpen={
|
||||
index < 2 ||
|
||||
group.checks.some((c) => c.status === 'error' || c.status === 'warning')
|
||||
}
|
||||
/>
|
||||
</MonitorCard>
|
||||
))}
|
||||
</MonitorGrid>
|
||||
{/* Detailed Audit */}
|
||||
{data?.groups && <HealthAuditSection groups={data.groups} />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t pt-4 text-xs text-muted-foreground">
|
||||
<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">
|
||||
{/* 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>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-2 animate-pulse rounded-full bg-emerald-500" />
|
||||
<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>
|
||||
</MonitorLayout>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthLoadingSkeleton() {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title={<Skeleton className="h-6 w-48" />} />
|
||||
<MonitorLayout
|
||||
kpis={
|
||||
<KpiRow>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-xl" />
|
||||
))}
|
||||
</KpiRow>
|
||||
}
|
||||
>
|
||||
<MonitorGrid>
|
||||
<MonitorCard span={12}>
|
||||
<Skeleton className="h-48 w-full rounded" />
|
||||
</MonitorCard>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<MonitorCard key={i} span={12}>
|
||||
<Skeleton className="h-16 w-full rounded" />
|
||||
</MonitorCard>
|
||||
))}
|
||||
</MonitorGrid>
|
||||
</MonitorLayout>
|
||||
</PageShell>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user