mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
refactor(ui): organize health components into health/ directory
- move health-card, health-gauge, health-check-item - move health-group-section, health-stats-bar - add barrel export in health/index.ts
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
|
||||
import { useFixHealth } from '@/hooks/use-health';
|
||||
|
||||
interface HealthCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
message: string;
|
||||
details?: string;
|
||||
fixable?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
ok: {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-600',
|
||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: 'text-yellow-500',
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-500',
|
||||
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
},
|
||||
};
|
||||
|
||||
export function HealthCard({ check }: { check: HealthCheck }) {
|
||||
const fixMutation = useFixHealth();
|
||||
const config = statusConfig[check.status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Card className={`${config.bg} ${config.border} border`}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||
<span className="font-medium">{check.name}</span>
|
||||
</div>
|
||||
{check.fixable && check.status !== 'ok' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fixMutation.mutate(check.id)}
|
||||
disabled={fixMutation.isPending}
|
||||
>
|
||||
<Wrench className="w-3 h-3 mr-1" />
|
||||
Fix
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">{check.message}</p>
|
||||
{check.details && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">{check.details}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ChevronRight, Copy, Terminal, Wrench } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useFixHealth, type HealthCheck } from '@/hooks/use-health';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const statusConfig = {
|
||||
ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' },
|
||||
warning: { dot: 'bg-yellow-500', label: 'WARN', labelColor: 'text-yellow-500' },
|
||||
error: { dot: 'bg-red-500', label: 'ERR', labelColor: 'text-red-500' },
|
||||
info: { dot: 'bg-blue-500', label: 'INFO', labelColor: 'text-blue-500' },
|
||||
};
|
||||
|
||||
export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
||||
const fixMutation = useFixHealth();
|
||||
const config = statusConfig[check.status];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
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) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-3 px-3 py-2 rounded-lg',
|
||||
'hover:bg-muted/50 transition-colors duration-150',
|
||||
'border border-transparent hover:border-border/50'
|
||||
)}
|
||||
>
|
||||
{/* Status dot with pulse animation */}
|
||||
<div className="relative flex items-center justify-center w-4 h-4">
|
||||
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
|
||||
{check.status !== 'ok' && (
|
||||
<div
|
||||
className={cn('absolute w-2 h-2 rounded-full animate-ping opacity-75', config.dot)}
|
||||
/>
|
||||
)}
|
||||
</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' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => fixMutation.mutate(check.id)}
|
||||
disabled={fixMutation.isPending}
|
||||
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Wrench className="w-3 h-3 mr-1" />
|
||||
Fix
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expandable display for items with details or fix commands
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-lg border transition-all duration-150',
|
||||
isOpen
|
||||
? 'border-border bg-muted/30'
|
||||
: 'border-transparent hover:border-border/50 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="w-full flex items-center gap-3 px-3 py-2 text-left">
|
||||
{/* Status dot */}
|
||||
<div className="relative flex items-center justify-center w-4 h-4">
|
||||
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
|
||||
{check.status !== 'ok' && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute w-2 h-2 rounded-full animate-ping opacity-75',
|
||||
config.dot
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Chevron indicator */}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<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 && (
|
||||
<pre className="text-xs font-mono text-muted-foreground bg-background/50 rounded p-2 overflow-x-auto border border-border/50">
|
||||
{check.details}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Fix command block */}
|
||||
{check.fix && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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-3 h-3 text-muted-foreground shrink-0" />
|
||||
<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>
|
||||
|
||||
{check.fixable && check.status !== 'ok' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => fixMutation.mutate(check.id)}
|
||||
disabled={fixMutation.isPending}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
<Wrench className="w-3 h-3 mr-1" />
|
||||
Apply Fix
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -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 './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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Health Components Barrel Export
|
||||
*/
|
||||
|
||||
export { HealthCard } from './health-card';
|
||||
export { HealthCheckItem } from './health-check-item';
|
||||
export { HealthGauge } from './health-gauge';
|
||||
export { HealthGroupSection } from './health-group-section';
|
||||
export { HealthStatsBar } from './health-stats-bar';
|
||||
Reference in New Issue
Block a user