mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
feat(ui): migrate health page to design system Monitor archetype
Hero gauge + status copy moved into a MonitorCard variant=terminal, preserving the ccs doctor / live-ops aesthetic per locked decision #4. HealthStatsBar replaced by a 4-column KpiRow with severity tones (Checks / Passed / Warnings / Errors). Each HealthGroupSection becomes its own MonitorCard while keeping the existing accordion behavior intact -- so check expansion, sort-by-severity, and live monitoring continue to work unchanged. PageHeader carries the version badge, last-scan relative time, auto-refresh hint, ccs doctor copy action, and refresh button. Pure layout + visual hierarchy migration -- no new metrics, no new features. LOC: 263 -> ~245.
This commit is contained in:
+184
-176
@@ -1,15 +1,31 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react';
|
||||
import { HealthGauge } from '@/components/health/health-gauge';
|
||||
import { HealthStatsBar } from '@/components/health/health-stats-bar';
|
||||
import { HealthGroupSection } from '@/components/health/health-group-section';
|
||||
import { useHealth, type HealthGroup } from '@/hooks/use-health';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageShell, PageHeader } from '@/components/page-shell';
|
||||
import {
|
||||
MonitorLayout,
|
||||
KpiRow,
|
||||
KpiCard,
|
||||
MonitorGrid,
|
||||
MonitorCard,
|
||||
} from '@/components/monitor-layout';
|
||||
|
||||
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
|
||||
if (summary.errors > 0) return 'error';
|
||||
@@ -41,57 +57,16 @@ function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
|
||||
});
|
||||
}
|
||||
|
||||
function TerminalHeader() {
|
||||
return (
|
||||
<div className="font-mono text-sm text-muted-foreground flex items-center gap-2">
|
||||
<span className="text-green-500">$</span>
|
||||
<span>ccs doctor</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
{/* Hero skeleton */}
|
||||
<div className="rounded-xl border bg-gradient-to-br from-background to-muted/20 p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="w-[120px] h-[120px] rounded-full" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats skeleton */}
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
|
||||
{/* Groups skeleton */}
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
||||
|
||||
// Use dataUpdatedAt directly instead of storing in state
|
||||
const lastRefresh = dataUpdatedAt;
|
||||
|
||||
// Update relative time display by forcing re-render every second
|
||||
// Force re-render every second so the relative-time label stays fresh.
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick((t) => t + 1), 1000);
|
||||
const interval = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
// Consume tick to prevent unused variable warning
|
||||
void tick;
|
||||
|
||||
const copyDoctorCommand = () => {
|
||||
@@ -105,94 +80,46 @@ export function HealthPage() {
|
||||
};
|
||||
|
||||
if (isLoading && !data) {
|
||||
return <LoadingSkeleton />;
|
||||
return <HealthLoadingSkeleton />;
|
||||
}
|
||||
|
||||
const overallStatus = data ? getOverallStatus(data.summary) : 'ok';
|
||||
const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
{/* Hero Section - Terminal-inspired control center header */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl border p-6',
|
||||
'bg-gradient-to-br from-background via-background to-muted/30'
|
||||
)}
|
||||
>
|
||||
{/* Subtle scan lines effect */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.02] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 2px, currentColor 2px, currentColor 3px)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Grid pattern background */}
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
{/* Left: Health Gauge - excludes info from percentage */}
|
||||
{data && (
|
||||
<div className="shrink-0">
|
||||
<HealthGauge
|
||||
passed={data.summary.passed}
|
||||
total={data.summary.total - data.summary.info}
|
||||
status={overallStatus}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center: Title and status */}
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Terminal prompt */}
|
||||
<TerminalHeader />
|
||||
|
||||
{/* Main title */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold font-mono tracking-tight">
|
||||
{t('health.systemHealth')}
|
||||
</h1>
|
||||
{data?.version && (
|
||||
<Badge variant="outline" className="font-mono text-xs bg-muted/50">
|
||||
{t('health.build', { version: data.version })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{t('health.lastScan')}</span>
|
||||
<span className="font-mono">
|
||||
{lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{t('health.autoRefresh')}</span>
|
||||
<span className="font-mono text-green-500">30s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<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="w-3 h-3" />
|
||||
<Terminal className="size-3.5" />
|
||||
ccs doctor
|
||||
<Copy className="w-3 h-3 opacity-50" />
|
||||
<Copy className="size-3 opacity-50" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -201,63 +128,144 @@ export function HealthPage() {
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
<RefreshCw className={cn('size-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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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="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>
|
||||
</div>
|
||||
</MonitorCard>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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">
|
||||
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-2 animate-pulse rounded-full bg-emerald-500" />
|
||||
<span>{t('health.liveMonitoring')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MonitorLayout>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Stats Bar */}
|
||||
{data && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<HealthStatsBar
|
||||
total={data.summary.total}
|
||||
passed={data.summary.passed}
|
||||
warnings={data.summary.warnings}
|
||||
errors={data.summary.errors}
|
||||
info={data.summary.info}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Health Check Groups - Single column layout */}
|
||||
{sortedGroups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{sortedGroups.map((group, index) => (
|
||||
<HealthGroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
defaultOpen={
|
||||
index < 2 ||
|
||||
group.checks.some((c) => c.status === 'error' || c.status === 'warning')
|
||||
}
|
||||
/>
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer metadata */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
{t('health.version')} <span className="font-mono">{data?.version ?? '--'}</span>
|
||||
</span>
|
||||
<span>
|
||||
{t('health.platform')}{' '}
|
||||
<span className="font-mono">
|
||||
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span>{t('health.liveMonitoring')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MonitorGrid>
|
||||
</MonitorLayout>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user