mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-20 18:23:23 +00:00
feat(ui): add modular health dashboard components
- Add health-gauge component for visual status representation - Add health-group-section for organized health checks display - Add health-stats-bar for summary statistics - Refactor health-check-item with improved structure - Update health.tsx to use new modular components
This commit is contained in:
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
<!-- Theme color for mobile browsers -->
|
<!-- Theme color for mobile browsers -->
|
||||||
<meta name="theme-color" content="#0a0a0a" />
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
|
|
||||||
|
<!-- Google Fonts - JetBrains Mono (terminal) + IBM Plex Sans (UI) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,170 +1,144 @@
|
|||||||
import {
|
import { ChevronRight, Copy, Terminal, Wrench } from 'lucide-react';
|
||||||
CheckCircle2,
|
|
||||||
AlertTriangle,
|
|
||||||
XCircle,
|
|
||||||
Info,
|
|
||||||
Wrench,
|
|
||||||
ChevronDown,
|
|
||||||
Terminal,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { useFixHealth, type HealthCheck } from '@/hooks/use-health';
|
import { useFixHealth, type HealthCheck } from '@/hooks/use-health';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
ok: {
|
ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' },
|
||||||
icon: CheckCircle2,
|
warning: { dot: 'bg-yellow-500', label: 'WARN', labelColor: 'text-yellow-500' },
|
||||||
color: 'text-green-600',
|
error: { dot: 'bg-red-500', label: 'ERR', labelColor: 'text-red-500' },
|
||||||
bg: 'bg-green-500/5',
|
info: { dot: 'bg-blue-500', label: 'INFO', labelColor: 'text-blue-500' },
|
||||||
border: 'border-green-500/20',
|
|
||||||
label: '[OK]',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
icon: AlertTriangle,
|
|
||||||
color: 'text-yellow-500',
|
|
||||||
bg: 'bg-yellow-500/5',
|
|
||||||
border: 'border-yellow-500/20',
|
|
||||||
label: '[!]',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
icon: XCircle,
|
|
||||||
color: 'text-red-500',
|
|
||||||
bg: 'bg-red-500/5',
|
|
||||||
border: 'border-red-500/20',
|
|
||||||
label: '[X]',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
icon: Info,
|
|
||||||
color: 'text-blue-500',
|
|
||||||
bg: 'bg-blue-500/5',
|
|
||||||
border: 'border-blue-500/20',
|
|
||||||
label: '[i]',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
||||||
const fixMutation = useFixHealth();
|
const fixMutation = useFixHealth();
|
||||||
const config = statusConfig[check.status];
|
const config = statusConfig[check.status];
|
||||||
const Icon = config.icon;
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const hasExpandableContent = check.details || check.fix;
|
const hasExpandableContent = check.details || check.fix;
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compact single-line display for items without expandable content
|
||||||
if (!hasExpandableContent) {
|
if (!hasExpandableContent) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-start gap-4 p-4 rounded-xl border transition-all duration-200',
|
'group flex items-center gap-3 px-3 py-2 rounded-lg',
|
||||||
'hover:shadow-sm',
|
'hover:bg-muted/50 transition-colors duration-150',
|
||||||
config.bg,
|
'border border-transparent hover:border-border/50'
|
||||||
config.border
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
{/* Status dot with pulse animation */}
|
||||||
className={cn(
|
<div className="relative flex items-center justify-center w-4 h-4">
|
||||||
'mt-0.5 p-2 rounded-full bg-background/50 backdrop-blur-sm shadow-sm',
|
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
|
||||||
config.color
|
{check.status !== 'ok' && (
|
||||||
|
<div
|
||||||
|
className={cn('absolute w-2 h-2 rounded-full animate-ping opacity-75', config.dot)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 py-0.5">
|
|
||||||
<div className="flex items-center justify-between gap-4 mb-1">
|
|
||||||
<h4 className="text-base font-semibold tracking-tight">{check.name}</h4>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-xs font-mono font-medium px-2 py-0.5 rounded-full bg-background/50',
|
|
||||||
config.color
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{check.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Check name */}
|
||||||
|
<span className="flex-1 text-sm font-medium truncate">{check.name}</span>
|
||||||
|
|
||||||
|
{/* Status label */}
|
||||||
|
<span className={cn('font-mono text-xs font-semibold', config.labelColor)}>
|
||||||
|
[{config.label}]
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Fix button for fixable non-ok items */}
|
||||||
{check.fixable && check.status !== 'ok' && (
|
{check.fixable && check.status !== 'ok' && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
onClick={() => fixMutation.mutate(check.id)}
|
onClick={() => fixMutation.mutate(check.id)}
|
||||||
disabled={fixMutation.isPending}
|
disabled={fixMutation.isPending}
|
||||||
className="h-9 px-4 ml-2 self-center bg-background shadow-sm hover:bg-background/80"
|
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<Wrench className="w-3.5 h-3.5 mr-2" />
|
<Wrench className="w-3 h-3 mr-1" />
|
||||||
Fix Issue
|
Fix
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expandable display for items with details or fix commands
|
||||||
return (
|
return (
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group rounded-xl border transition-all duration-200 hover:shadow-sm',
|
'group rounded-lg border transition-all duration-150',
|
||||||
config.bg,
|
isOpen
|
||||||
config.border
|
? 'border-border bg-muted/30'
|
||||||
|
: 'border-transparent hover:border-border/50 hover:bg-muted/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button className="w-full flex items-center gap-3 px-3 py-2 text-left">
|
||||||
className={cn(
|
{/* Status dot */}
|
||||||
'w-full flex items-start gap-4 p-4 text-left rounded-xl transition-all',
|
<div className="relative flex items-center justify-center w-4 h-4">
|
||||||
isOpen && 'rounded-b-none border-b border-border/10'
|
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
|
||||||
)}
|
{check.status !== 'ok' && (
|
||||||
>
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
'absolute w-2 h-2 rounded-full animate-ping opacity-75',
|
||||||
'mt-0.5 p-2 rounded-full bg-background/50 backdrop-blur-sm shadow-sm',
|
config.dot
|
||||||
config.color
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 py-0.5">
|
{/* Check name */}
|
||||||
<div className="flex items-center justify-between gap-4 mb-1">
|
<span className="flex-1 text-sm font-medium truncate">{check.name}</span>
|
||||||
<h4 className="text-base font-semibold tracking-tight">{check.name}</h4>
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Status label */}
|
||||||
<span
|
<span className={cn('font-mono text-xs font-semibold', config.labelColor)}>
|
||||||
className={cn(
|
[{config.label}]
|
||||||
'text-xs font-mono font-medium px-2 py-0.5 rounded-full bg-background/50',
|
</span>
|
||||||
config.color
|
|
||||||
)}
|
{/* Chevron indicator */}
|
||||||
>
|
<ChevronRight
|
||||||
{config.label}
|
className={cn(
|
||||||
</span>
|
'w-4 h-4 text-muted-foreground transition-transform duration-200',
|
||||||
<ChevronDown
|
isOpen && 'rotate-90'
|
||||||
className={cn(
|
)}
|
||||||
'w-5 h-5 text-muted-foreground/70 transition-transform duration-200',
|
/>
|
||||||
isOpen && 'rotate-180'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{check.message}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="p-4 pt-2 space-y-3">
|
<div className="px-3 pb-3 pt-1 space-y-2 ml-7">
|
||||||
|
{/* Message */}
|
||||||
|
<p className="text-xs text-muted-foreground">{check.message}</p>
|
||||||
|
|
||||||
|
{/* Details block */}
|
||||||
{check.details && (
|
{check.details && (
|
||||||
<div className="bg-background/50 rounded-lg p-3 border border-border/10">
|
<pre className="text-xs font-mono text-muted-foreground bg-background/50 rounded p-2 overflow-x-auto border border-border/50">
|
||||||
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
|
{check.details}
|
||||||
{check.details}
|
</pre>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fix command block */}
|
||||||
{check.fix && (
|
{check.fix && (
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 bg-background/50 rounded-lg p-3 border border-border/10 flex items-center gap-3">
|
<div className="flex-1 flex items-center gap-2 bg-background/50 rounded px-2 py-1.5 border border-border/50">
|
||||||
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
|
<Terminal className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||||
<code className="text-xs font-mono text-foreground break-all">{check.fix}</code>
|
<code className="text-xs font-mono flex-1 truncate">{check.fix}</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => check.fix && copyToClipboard(check.fix)}
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{check.fixable && check.status !== 'ok' && (
|
{check.fixable && check.status !== 'ok' && (
|
||||||
@@ -172,9 +146,9 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => fixMutation.mutate(check.id)}
|
onClick={() => fixMutation.mutate(check.id)}
|
||||||
disabled={fixMutation.isPending}
|
disabled={fixMutation.isPending}
|
||||||
className="h-auto py-3 px-6 shadow-sm shrink-0"
|
className="h-7 px-3 text-xs"
|
||||||
>
|
>
|
||||||
<Wrench className="w-4 h-4 mr-2" />
|
<Wrench className="w-3 h-3 mr-1" />
|
||||||
Apply Fix
|
Apply Fix
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface HealthGaugeProps {
|
||||||
|
passed: number;
|
||||||
|
total: number;
|
||||||
|
status: 'ok' | 'warning' | 'error';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeConfig = {
|
||||||
|
sm: { dimension: 80, strokeWidth: 6, fontSize: 'text-lg', labelSize: 'text-[10px]' },
|
||||||
|
md: { dimension: 120, strokeWidth: 8, fontSize: 'text-3xl', labelSize: 'text-xs' },
|
||||||
|
lg: { dimension: 160, strokeWidth: 10, fontSize: 'text-4xl', labelSize: 'text-sm' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
ok: { stroke: '#22C55E', glow: 'rgba(34, 197, 94, 0.4)' },
|
||||||
|
warning: { stroke: '#EAB308', glow: 'rgba(234, 179, 8, 0.4)' },
|
||||||
|
error: { stroke: '#EF4444', glow: 'rgba(239, 68, 68, 0.4)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) {
|
||||||
|
const config = sizeConfig[size];
|
||||||
|
const colors = statusColors[status];
|
||||||
|
const percentage = total > 0 ? Math.round((passed / total) * 100) : 0;
|
||||||
|
|
||||||
|
const radius = (config.dimension - config.strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
||||||
|
const center = config.dimension / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
width={config.dimension}
|
||||||
|
height={config.dimension}
|
||||||
|
className="transform -rotate-90"
|
||||||
|
style={{ filter: `drop-shadow(0 0 8px ${colors.glow})` }}
|
||||||
|
>
|
||||||
|
{/* Background track */}
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={config.strokeWidth}
|
||||||
|
className="text-muted/30"
|
||||||
|
/>
|
||||||
|
{/* Progress arc */}
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.stroke}
|
||||||
|
strokeWidth={config.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
className="transition-all duration-1000 ease-out"
|
||||||
|
/>
|
||||||
|
{/* Animated glow dot at end of arc */}
|
||||||
|
{percentage > 0 && (
|
||||||
|
<circle
|
||||||
|
cx={center + radius * Math.cos((percentage / 100) * 2 * Math.PI - Math.PI / 2)}
|
||||||
|
cy={center + radius * Math.sin((percentage / 100) * 2 * Math.PI - Math.PI / 2)}
|
||||||
|
r={config.strokeWidth / 2}
|
||||||
|
fill={colors.stroke}
|
||||||
|
className="animate-pulse"
|
||||||
|
style={{ filter: `drop-shadow(0 0 4px ${colors.glow})` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{/* Center content */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className={cn('font-mono font-bold tracking-tight', config.fontSize)}>
|
||||||
|
{percentage}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono uppercase tracking-widest text-muted-foreground',
|
||||||
|
config.labelSize
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
health
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { ChevronDown, Monitor, Settings, Users, Shield, Zap } from 'lucide-react';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { HealthCheckItem } from '@/components/health-check-item';
|
||||||
|
import { type HealthGroup } from '@/hooks/use-health';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const groupIcons: Record<string, typeof Monitor> = {
|
||||||
|
Monitor,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HealthGroupSectionProps {
|
||||||
|
group: HealthGroup;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthGroupSection({ group, defaultOpen = true }: HealthGroupSectionProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
const Icon = groupIcons[group.icon] || Monitor;
|
||||||
|
|
||||||
|
const passed = group.checks.filter((c) => c.status === 'ok').length;
|
||||||
|
const total = group.checks.length;
|
||||||
|
const hasErrors = group.checks.some((c) => c.status === 'error');
|
||||||
|
const hasWarnings = group.checks.some((c) => c.status === 'warning');
|
||||||
|
const percentage = Math.round((passed / total) * 100);
|
||||||
|
|
||||||
|
// Determine status color
|
||||||
|
const statusColor = hasErrors
|
||||||
|
? 'text-red-500'
|
||||||
|
: hasWarnings
|
||||||
|
? 'text-yellow-500'
|
||||||
|
: 'text-green-500';
|
||||||
|
const progressColor = hasErrors ? 'bg-red-500' : hasWarnings ? 'bg-yellow-500' : 'bg-green-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border transition-all duration-200',
|
||||||
|
hasErrors ? 'border-red-500/30' : hasWarnings ? 'border-yellow-500/30' : 'border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Group header */}
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 p-3 text-left rounded-lg',
|
||||||
|
'hover:bg-muted/50 transition-colors duration-150',
|
||||||
|
isOpen && 'rounded-b-none border-b border-border/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Group icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded-md',
|
||||||
|
hasErrors
|
||||||
|
? 'bg-red-500/10 text-red-500'
|
||||||
|
: hasWarnings
|
||||||
|
? 'bg-yellow-500/10 text-yellow-500'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group name */}
|
||||||
|
<span className="flex-1 text-sm font-semibold">{group.name}</span>
|
||||||
|
|
||||||
|
{/* Progress indicator (collapsed view) */}
|
||||||
|
{!isOpen && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn('h-full transition-all duration-500', progressColor)}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Count badge */}
|
||||||
|
<span className={cn('font-mono text-xs font-semibold', statusColor)}>
|
||||||
|
{passed}/{total}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 text-muted-foreground transition-transform duration-200',
|
||||||
|
isOpen && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
{/* Checks list */}
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{group.checks.map((check) => (
|
||||||
|
<HealthCheckItem key={check.id} check={check} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface HealthStatsBarProps {
|
||||||
|
total: number;
|
||||||
|
passed: number;
|
||||||
|
warnings: number;
|
||||||
|
errors: number;
|
||||||
|
info: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatItemProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatItem({ label, value, color, bgColor }: StatItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn('w-2 h-2 rounded-full animate-pulse', bgColor)} />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className={cn('font-mono font-bold text-sm', color)}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) {
|
||||||
|
// Calculate percentages for the progress bar
|
||||||
|
const passedPct = (passed / total) * 100;
|
||||||
|
const warningPct = (warnings / total) * 100;
|
||||||
|
const errorPct = (errors / total) * 100;
|
||||||
|
const infoPct = (info / total) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Progress bar visualization */}
|
||||||
|
<div className="h-2 rounded-full overflow-hidden bg-muted/50 flex">
|
||||||
|
{errorPct > 0 && (
|
||||||
|
<div
|
||||||
|
className="h-full bg-red-500 transition-all duration-500"
|
||||||
|
style={{ width: `${errorPct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{warningPct > 0 && (
|
||||||
|
<div
|
||||||
|
className="h-full bg-yellow-500 transition-all duration-500"
|
||||||
|
style={{ width: `${warningPct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{infoPct > 0 && (
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all duration-500"
|
||||||
|
style={{ width: `${infoPct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{passedPct > 0 && (
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500 transition-all duration-500"
|
||||||
|
style={{ width: `${passedPct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Checks
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-bold text-lg">{total}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<StatItem label="OK" value={passed} color="text-green-500" bgColor="bg-green-500" />
|
||||||
|
<StatItem label="WARN" value={warnings} color="text-yellow-500" bgColor="bg-yellow-500" />
|
||||||
|
<StatItem label="ERR" value={errors} color="text-red-500" bgColor="bg-red-500" />
|
||||||
|
<StatItem label="INFO" value={info} color="text-blue-500" bgColor="bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-3
@@ -120,11 +120,12 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: 'Fira Sans', system-ui, sans-serif;
|
font-family: 'IBM Plex Sans', 'Fira Sans', system-ui, sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
code,
|
code,
|
||||||
pre {
|
pre,
|
||||||
font-family: 'Fira Code', monospace;
|
.font-mono {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-251
@@ -1,58 +1,14 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react';
|
||||||
RefreshCw,
|
import { HealthGauge } from '@/components/health-gauge';
|
||||||
CheckCircle2,
|
import { HealthStatsBar } from '@/components/health-stats-bar';
|
||||||
AlertTriangle,
|
import { HealthGroupSection } from '@/components/health-group-section';
|
||||||
XCircle,
|
|
||||||
Info,
|
|
||||||
Monitor,
|
|
||||||
Settings,
|
|
||||||
Users,
|
|
||||||
Shield,
|
|
||||||
Zap,
|
|
||||||
Stethoscope,
|
|
||||||
Copy,
|
|
||||||
Terminal,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { HealthCheckItem } from '@/components/health-check-item';
|
|
||||||
import { useHealth, type HealthGroup } from '@/hooks/use-health';
|
import { useHealth, type HealthGroup } from '@/hooks/use-health';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
const groupIcons: Record<string, typeof Monitor> = {
|
|
||||||
Monitor,
|
|
||||||
Settings,
|
|
||||||
Users,
|
|
||||||
Shield,
|
|
||||||
Zap,
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
ok: {
|
|
||||||
icon: CheckCircle2,
|
|
||||||
label: 'All Systems Operational',
|
|
||||||
color: 'text-green-600',
|
|
||||||
bg: 'bg-green-500/10',
|
|
||||||
border: 'border-green-500/20',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
icon: AlertTriangle,
|
|
||||||
label: 'Some Issues Detected',
|
|
||||||
color: 'text-yellow-500',
|
|
||||||
bg: 'bg-yellow-500/10',
|
|
||||||
border: 'border-yellow-500/20',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
icon: XCircle,
|
|
||||||
label: 'Action Required',
|
|
||||||
color: 'text-red-500',
|
|
||||||
bg: 'bg-red-500/10',
|
|
||||||
border: 'border-red-500/20',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
|
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
|
||||||
if (summary.errors > 0) return 'error';
|
if (summary.errors > 0) return 'error';
|
||||||
@@ -60,121 +16,60 @@ function getOverallStatus(summary: { passed: number; warnings: number; errors: n
|
|||||||
return 'ok';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
function HealthGroupSection({ group }: { group: HealthGroup }) {
|
function formatRelativeTime(timestamp: number): string {
|
||||||
const Icon = groupIcons[group.icon] || Monitor;
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
|
if (seconds < 5) return 'just now';
|
||||||
const groupPassed = group.checks.filter((c) => c.status === 'ok').length;
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
const groupTotal = group.checks.length;
|
const minutes = Math.floor(seconds / 60);
|
||||||
const hasIssues = group.checks.some((c) => c.status === 'error' || c.status === 'warning');
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
return (
|
return `${hours}h ago`;
|
||||||
<Card className={cn('transition-all duration-200', hasIssues && 'border-yellow-500/30')}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'p-1.5 rounded-md',
|
|
||||||
hasIssues ? 'bg-yellow-500/10 text-yellow-500' : 'bg-muted text-muted-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
{group.name}
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant="secondary" className="font-mono text-xs">
|
|
||||||
{groupPassed}/{groupTotal}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{group.checks.map((check) => (
|
|
||||||
<HealthCheckItem key={check.id} check={check} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryCard({
|
function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
|
||||||
label,
|
return [...groups].sort((a, b) => {
|
||||||
value,
|
const aErrors = a.checks.filter((c) => c.status === 'error').length;
|
||||||
icon: Icon,
|
const bErrors = b.checks.filter((c) => c.status === 'error').length;
|
||||||
color,
|
const aWarnings = a.checks.filter((c) => c.status === 'warning').length;
|
||||||
}: {
|
const bWarnings = b.checks.filter((c) => c.status === 'warning').length;
|
||||||
label: string;
|
if (aErrors !== bErrors) return bErrors - aErrors;
|
||||||
value: number;
|
return bWarnings - aWarnings;
|
||||||
icon: typeof CheckCircle2;
|
});
|
||||||
color: string;
|
}
|
||||||
}) {
|
|
||||||
|
function TerminalHeader() {
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-all duration-200">
|
<div className="font-mono text-sm text-muted-foreground flex items-center gap-2">
|
||||||
<CardContent className="p-0">
|
<span className="text-green-500">$</span>
|
||||||
<div className="flex items-center gap-4 p-4 bg-card">
|
<span>ccs doctor</span>
|
||||||
<div
|
</div>
|
||||||
className={cn(
|
|
||||||
'p-3 rounded-xl bg-background shadow-sm border',
|
|
||||||
color
|
|
||||||
.replace('text-', 'text-opacity-80 border-')
|
|
||||||
.replace('600', '200')
|
|
||||||
.replace('500', '200')
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className={cn('w-6 h-6', color)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={cn('text-3xl font-bold font-mono tracking-tight', color)}>{value}</p>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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-6">
|
||||||
{/* Hero Skeleton */}
|
{/* Hero skeleton */}
|
||||||
<div className="rounded-xl border p-6">
|
<div className="rounded-xl border bg-gradient-to-br from-background to-muted/20 p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-6">
|
||||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
<Skeleton className="w-[120px] h-[120px] rounded-full" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 space-y-3">
|
||||||
<Skeleton className="h-7 w-[240px] mb-2" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="h-4 w-[180px]" />
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-10 w-24" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Skeleton */}
|
{/* Stats skeleton */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<Skeleton className="h-16 w-full rounded-lg" />
|
||||||
|
|
||||||
|
{/* Groups skeleton */}
|
||||||
|
<div className="space-y-3">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<Skeleton key={i} className="h-20 rounded-lg" />
|
<Skeleton key={i} className="h-20 rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Groups Skeleton */}
|
|
||||||
<div className="columns-1 md:columns-2 gap-4 space-y-4">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<div key={i} className="break-inside-avoid">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<Skeleton className="h-5 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[1, 2, 3].map((j) => (
|
|
||||||
<Skeleton key={j} className="h-12 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -182,33 +77,53 @@ function LoadingSkeleton() {
|
|||||||
export function HealthPage() {
|
export function HealthPage() {
|
||||||
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
||||||
|
|
||||||
const formatTime = (timestamp: number) => {
|
// Use dataUpdatedAt directly instead of storing in state
|
||||||
return new Date(timestamp).toLocaleTimeString();
|
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 = () => {
|
const copyDoctorCommand = () => {
|
||||||
navigator.clipboard.writeText('ccs doctor');
|
navigator.clipboard.writeText('ccs doctor');
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refetch();
|
||||||
|
toast.info('Refreshing health checks...');
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading && !data) {
|
if (isLoading && !data) {
|
||||||
return <LoadingSkeleton />;
|
return <LoadingSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overallStatus = data ? getOverallStatus(data.summary) : 'ok';
|
const overallStatus = data ? getOverallStatus(data.summary) : 'ok';
|
||||||
const status = statusConfig[overallStatus];
|
const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : [];
|
||||||
const StatusIcon = status.icon;
|
|
||||||
|
|
||||||
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-6">
|
||||||
{/* Hero Section */}
|
{/* Hero Section - Terminal-inspired control center header */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative overflow-hidden rounded-xl border p-6',
|
'relative overflow-hidden rounded-xl border p-6',
|
||||||
'bg-gradient-to-br from-background via-background to-muted/30'
|
'bg-gradient-to-br from-background via-background to-muted/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Subtle background pattern */}
|
{/* Subtle scan lines effect */}
|
||||||
|
<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 opacity-[0.03] pointer-events-none">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
@@ -219,129 +134,123 @@ export function HealthPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div className="relative flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||||
{/* Left: Title and Status */}
|
{/* Left: Health Gauge - excludes info from percentage */}
|
||||||
<div className="flex items-center gap-4">
|
{data && (
|
||||||
<div className={cn('p-3 rounded-xl', status.bg)}>
|
<div className="shrink-0">
|
||||||
<Stethoscope className={cn('w-8 h-8', status.color)} />
|
<HealthGauge
|
||||||
|
passed={data.summary.passed}
|
||||||
|
total={data.summary.total - data.summary.info}
|
||||||
|
status={overallStatus}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-2xl font-bold">Health Check</h1>
|
{/* Center: Title and status */}
|
||||||
{data?.version && (
|
<div className="flex-1 space-y-3">
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
{/* Terminal prompt */}
|
||||||
v{data.version}
|
<TerminalHeader />
|
||||||
</Badge>
|
|
||||||
)}
|
{/* Main title */}
|
||||||
</div>
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<h1 className="text-2xl font-bold font-mono tracking-tight">System Health</h1>
|
||||||
<StatusIcon className={cn('w-4 h-4', status.color)} />
|
{data?.version && (
|
||||||
<span className={cn('text-sm font-medium', status.color)}>{status.label}</span>
|
<Badge variant="outline" className="font-mono text-xs bg-muted/50">
|
||||||
</div>
|
build {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">Last scan:</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{lastRefresh ? formatRelativeTime(lastRefresh) : '--'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">|</span>
|
||||||
|
<span className="text-muted-foreground">Auto-refresh:</span>
|
||||||
|
<span className="font-mono text-green-500">30s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={copyDoctorCommand}
|
onClick={copyDoctorCommand}
|
||||||
className="gap-2 text-muted-foreground"
|
className="gap-2 font-mono text-xs"
|
||||||
>
|
>
|
||||||
<Terminal className="w-4 h-4" />
|
<Terminal className="w-3 h-3" />
|
||||||
ccs doctor
|
ccs doctor
|
||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3 h-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => refetch()} disabled={isLoading}>
|
<Button
|
||||||
<RefreshCw className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
|
variant="outline"
|
||||||
Refresh
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||||
|
<span className="hidden sm:inline">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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last check time */}
|
|
||||||
{dataUpdatedAt && (
|
|
||||||
<p className="relative text-xs text-muted-foreground mt-4">
|
|
||||||
Last check: {formatTime(dataUpdatedAt)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Stats */}
|
{/* Stats Bar */}
|
||||||
{data && (
|
{data && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<SummaryCard
|
<HealthStatsBar
|
||||||
label="Passed"
|
total={data.summary.total}
|
||||||
value={data.summary.passed}
|
passed={data.summary.passed}
|
||||||
icon={CheckCircle2}
|
warnings={data.summary.warnings}
|
||||||
color="text-green-600"
|
errors={data.summary.errors}
|
||||||
|
info={data.summary.info}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
|
||||||
label="Warnings"
|
|
||||||
value={data.summary.warnings}
|
|
||||||
icon={AlertTriangle}
|
|
||||||
color="text-yellow-500"
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
label="Errors"
|
|
||||||
value={data.summary.errors}
|
|
||||||
icon={XCircle}
|
|
||||||
color="text-red-500"
|
|
||||||
/>
|
|
||||||
<SummaryCard label="Info" value={data.summary.info} icon={Info} color="text-blue-500" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Health Check Groups */}
|
{/* Health Check Groups - Single column layout */}
|
||||||
{data?.groups && (
|
{sortedGroups.length > 0 && (
|
||||||
<div className="columns-1 md:columns-2 gap-4 space-y-4">
|
<div className="space-y-3">
|
||||||
{data.groups.map((group) => (
|
{sortedGroups.map((group, index) => (
|
||||||
<div key={group.id} className="break-inside-avoid">
|
<HealthGroupSection
|
||||||
<HealthGroupSection group={group} />
|
key={group.id}
|
||||||
</div>
|
group={group}
|
||||||
|
defaultOpen={
|
||||||
|
index < 2 ||
|
||||||
|
group.checks.some((c) => c.status === 'error' || c.status === 'warning')
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Issues Summary */}
|
{/* Footer metadata */}
|
||||||
{data && (data.summary.errors > 0 || data.summary.warnings > 0) && (
|
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
|
||||||
<Card className="border-yellow-500/30 bg-yellow-500/5">
|
<div className="flex items-center gap-4">
|
||||||
<CardHeader className="pb-3">
|
<span>
|
||||||
<CardTitle className="text-base flex items-center gap-2 text-yellow-600">
|
Version <span className="font-mono">{data?.version ?? '--'}</span>
|
||||||
<AlertTriangle className="w-4 h-4" />
|
</span>
|
||||||
Issues Detected
|
<span>
|
||||||
</CardTitle>
|
Platform{' '}
|
||||||
</CardHeader>
|
<span className="font-mono">
|
||||||
<CardContent className="pt-0">
|
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
|
||||||
<div className="space-y-2">
|
</span>
|
||||||
{data.checks
|
</span>
|
||||||
.filter((c) => c.status === 'error' || c.status === 'warning')
|
</div>
|
||||||
.map((check) => (
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
key={check.id}
|
<span>Live monitoring active</span>
|
||||||
className="flex items-start gap-3 p-3 rounded-lg bg-background border"
|
</div>
|
||||||
>
|
</div>
|
||||||
{check.status === 'error' ? (
|
|
||||||
<XCircle className="w-4 h-4 text-red-500 mt-0.5 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium">{check.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{check.message}</p>
|
|
||||||
{check.fix && (
|
|
||||||
<code className="mt-1 block text-xs bg-muted px-2 py-1 rounded font-mono text-muted-foreground">
|
|
||||||
{check.fix}
|
|
||||||
</code>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user