fix(dashboard): harden real-time dashboard with bug fixes and design compliance (#517)

* fix(dashboard): harden real-time dashboard with bug fixes and design compliance

- Fix host alert spam: add 5-minute cooldown for CPU/RAM/disk threshold
  alerts, preventing duplicate notifications every 30s during sustained
  breaches. Extract shared dispatchWithCooldown helper (also used by
  Docker janitor alerts).
- Fix memory metric inflation: subtract filesystem cache from stored
  memory_mb values, matching the existing calculateMemoryPercent logic.
- Fix crash detection reliability: replace fragile 'seconds ago' string
  matching with a tracked Set of alerted container IDs. Containers are
  only alerted once per crash event, with automatic cleanup when they
  start running again or after a 1-hour TTL.
- Fix health status bar: exited containers now trigger 'degraded' state
  independently of unread error notifications.
- Fix CPU chart Y-axis: auto-scale when aggregate container CPU exceeds
  100% instead of silently clipping at the hardcoded domain ceiling.
- Fix grammar: 'actives' to 'active' in container count label.
- Add shadow-card-bevel to all dashboard cards per design system.
- Update dashboard docs to reflect revised health status thresholds.

* test(dashboard): update monitor service tests for new alert signatures

- Add container Id fields to crash detection test fixtures
- Update host alert assertions to match dispatchWithCooldown 3-arg call
- Fix unhealthy container test to use State: 'unhealthy' instead of
  State: 'running' (running containers are now skipped in crash detect)
This commit is contained in:
Anso
2026-04-12 04:25:41 -04:00
committed by GitHub
parent c7f041957c
commit 9db97107aa
9 changed files with 98 additions and 53 deletions
+10 -7
View File
@@ -251,7 +251,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance(); const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' }); await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' });
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('CPU')); expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
}); });
it('does not dispatch when CPU below threshold', async () => { it('does not dispatch when CPU below threshold', async () => {
@@ -260,7 +260,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance(); const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' }); await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' });
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU')); expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
}); });
it('dispatches RAM warning when over threshold', async () => { it('dispatches RAM warning when over threshold', async () => {
@@ -269,7 +269,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance(); const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' }); await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Memory')); expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Memory'), undefined);
}); });
it('dispatches disk warning when over threshold', async () => { it('dispatches disk warning when over threshold', async () => {
@@ -278,7 +278,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance(); const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_disk_limit: '90' }); await (svc as any).evaluateGlobalSettings({ host_disk_limit: '90' });
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Disk')); expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Disk'), undefined);
}); });
it('skips host limits when threshold is 0 or NaN', async () => { it('skips host limits when threshold is 0 or NaN', async () => {
@@ -286,10 +286,10 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance(); const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '0' }); await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '0' });
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU')); expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: 'abc' }); await (svc as any).evaluateGlobalSettings({ host_cpu_limit: 'abc' });
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU')); expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
}); });
}); });
@@ -299,6 +299,7 @@ describe('MonitorService - global crash detection', () => {
it('detects exited containers with non-intentional exit codes', async () => { it('detects exited containers with non-intentional exit codes', async () => {
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]); mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
mockGetAllContainers.mockResolvedValue([{ mockGetAllContainers.mockResolvedValue([{
Id: 'crash-1',
State: 'exited', State: 'exited',
Status: 'Exited (1) 5 seconds ago', Status: 'Exited (1) 5 seconds ago',
Names: ['/my-container'], Names: ['/my-container'],
@@ -317,6 +318,7 @@ describe('MonitorService - global crash detection', () => {
for (const code of intentionalExits) { for (const code of intentionalExits) {
mockDispatchAlert.mockClear(); mockDispatchAlert.mockClear();
mockGetAllContainers.mockResolvedValue([{ mockGetAllContainers.mockResolvedValue([{
Id: `safe-${code}`,
State: 'exited', State: 'exited',
Status: `Exited (${code}) 5 seconds ago`, Status: `Exited (${code}) 5 seconds ago`,
Names: ['/safe-container'], Names: ['/safe-container'],
@@ -332,7 +334,8 @@ describe('MonitorService - global crash detection', () => {
it('detects unhealthy containers', async () => { it('detects unhealthy containers', async () => {
mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]); mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
mockGetAllContainers.mockResolvedValue([{ mockGetAllContainers.mockResolvedValue([{
State: 'running', Id: 'sick-1',
State: 'unhealthy',
Status: 'Up 2 hours (unhealthy)', Status: 'Up 2 hours (unhealthy)',
Names: ['/sick-container'], Names: ['/sick-container'],
}]); }]);
+70 -28
View File
@@ -30,6 +30,13 @@ interface AlertState {
breachStartedAt: number; // timestamp when the rule first breached breachStartedAt: number; // timestamp when the rule first breached
} }
const HOST_ALERT_KEYS = {
cpu: 'last_host_cpu_alert_ts',
ram: 'last_host_ram_alert_ts',
disk: 'last_host_disk_alert_ts',
janitor: 'last_janitor_alert_timestamp',
} as const;
export class MonitorService { export class MonitorService {
private static instance: MonitorService; private static instance: MonitorService;
private intervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null;
@@ -39,6 +46,11 @@ export class MonitorService {
// key: rule_id, value: AlertState // key: rule_id, value: AlertState
private activeBreaches = new Map<number, AlertState>(); private activeBreaches = new Map<number, AlertState>();
// Track containers that have already been alerted as crashed to avoid
// duplicate alerts. key: containerId, value: timestamp when alerted.
private alertedCrashes = new Map<string, number>();
private static readonly CRASH_ALERT_TTL_MS = 60 * 60 * 1000; // 1 hour
private constructor() { } private constructor() { }
public static getInstance(): MonitorService { public static getInstance(): MonitorService {
@@ -86,6 +98,7 @@ export class MonitorService {
private async evaluateGlobalSettings(settings: Record<string, string>) { private async evaluateGlobalSettings(settings: Record<string, string>) {
const notifier = NotificationService.getInstance(); const notifier = NotificationService.getInstance();
const HOST_ALERT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between repeat alerts
// 1. Host Limits // 1. Host Limits
try { try {
@@ -93,14 +106,16 @@ export class MonitorService {
const cpuUsage = currentLoad.currentLoad; const cpuUsage = currentLoad.currentLoad;
const cpuLimit = parseFloat(settings['host_cpu_limit']); const cpuLimit = parseFloat(settings['host_cpu_limit']);
if (!isNaN(cpuLimit) && cpuLimit > 0 && cpuUsage > cpuLimit) { if (!isNaN(cpuLimit) && cpuLimit > 0 && cpuUsage > cpuLimit) {
await notifier.dispatchAlert('warning', `Host CPU utilization is critically high: ${cpuUsage.toFixed(1)}% (Threshold: ${cpuLimit}%)`); await this.dispatchWithCooldown(HOST_ALERT_KEYS.cpu, HOST_ALERT_COOLDOWN_MS, 'warning',
`Host CPU utilization is critically high: ${cpuUsage.toFixed(1)}% (Threshold: ${cpuLimit}%)`);
} }
const mem = await si.mem(); const mem = await si.mem();
const ramUsage = (mem.used / mem.total) * 100; const ramUsage = (mem.used / mem.total) * 100;
const ramLimit = parseFloat(settings['host_ram_limit']); const ramLimit = parseFloat(settings['host_ram_limit']);
if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) { if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) {
await notifier.dispatchAlert('warning', `Host Memory utilization is critically high: ${ramUsage.toFixed(1)}% (Threshold: ${ramLimit}%)`); await this.dispatchWithCooldown(HOST_ALERT_KEYS.ram, HOST_ALERT_COOLDOWN_MS, 'warning',
`Host Memory utilization is critically high: ${ramUsage.toFixed(1)}% (Threshold: ${ramLimit}%)`);
} }
const fsSize = await si.fsSize(); const fsSize = await si.fsSize();
@@ -108,7 +123,8 @@ export class MonitorService {
if (mainDisk) { if (mainDisk) {
const diskLimit = parseFloat(settings['host_disk_limit']); const diskLimit = parseFloat(settings['host_disk_limit']);
if (!isNaN(diskLimit) && diskLimit > 0 && mainDisk.use > diskLimit) { if (!isNaN(diskLimit) && diskLimit > 0 && mainDisk.use > diskLimit) {
await notifier.dispatchAlert('warning', `Host Disk space utilization is critically high: ${mainDisk.use.toFixed(1)}% (Threshold: ${diskLimit}%)`); await this.dispatchWithCooldown(HOST_ALERT_KEYS.disk, HOST_ALERT_COOLDOWN_MS, 'warning',
`Host Disk space utilization is critically high: ${mainDisk.use.toFixed(1)}% (Threshold: ${diskLimit}%)`);
} }
} }
} catch (e) { } catch (e) {
@@ -117,36 +133,55 @@ export class MonitorService {
// 2. Global Crash Detect // 2. Global Crash Detect
if (settings['global_crash'] === '1') { if (settings['global_crash'] === '1') {
// Prune expired entries from the crash tracker
const now = Date.now();
for (const [id, ts] of this.alertedCrashes) {
if (now - ts > MonitorService.CRASH_ALERT_TTL_MS) this.alertedCrashes.delete(id);
}
try { try {
const nodes = DatabaseService.getInstance().getNodes(); const nodes = DatabaseService.getInstance().getNodes();
const runningIds = new Set<string>();
for (const node of nodes) { for (const node of nodes) {
if (!node.id) continue; if (!node.id) continue;
// Remote nodes run their own MonitorService locally - skip direct Docker access // Remote nodes run their own MonitorService locally
if (node.type === 'remote') continue; if (node.type === 'remote') continue;
try { try {
const docker = DockerController.getInstance(node.id); const docker = DockerController.getInstance(node.id);
const containers = await docker.getAllContainers(); const containers = await docker.getAllContainers();
for (const c of containers) { for (const c of containers) {
if (c.State === 'exited' || String(c.Status).includes('unhealthy')) { if (c.State === 'running') {
const containerStack = c.Labels?.['com.docker.compose.project'] || undefined; runningIds.add(c.Id);
if (c.State === 'exited') { continue;
if (c.Status.includes('seconds ago')) { }
const match = c.Status.match(/Exited \((\d+)\)/i); // Skip containers already alerted
const exitCode = match ? parseInt(match[1], 10) : null; if (this.alertedCrashes.has(c.Id)) continue;
const intentionalExitCodes = [0, 137, 143, 255];
if (exitCode !== null && !intentionalExitCodes.includes(exitCode)) { const containerStack = c.Labels?.['com.docker.compose.project'] || undefined;
await notifier.dispatchAlert('error', `[Node: ${node.name}] Container Crash Detected: ${c.Names[0]} exited unexpectedly (Code: ${exitCode}).`, containerStack);
} if (c.State === 'exited') {
} const match = c.Status.match(/Exited \((\d+)\)/i);
} else if (String(c.Status).includes('unhealthy')) { const exitCode = match ? parseInt(match[1], 10) : null;
await notifier.dispatchAlert('error', `[Node: ${node.name}] Healthcheck Failed: Container ${c.Names[0]} is unhealthy.`, containerStack); const intentionalExitCodes = [0, 137, 143, 255];
if (exitCode !== null && !intentionalExitCodes.includes(exitCode)) {
await notifier.dispatchAlert('error', `[Node: ${node.name}] Container Crash Detected: ${c.Names[0]} exited unexpectedly (Code: ${exitCode}).`, containerStack);
this.alertedCrashes.set(c.Id, now);
} }
} else if (String(c.Status).includes('unhealthy')) {
await notifier.dispatchAlert('error', `[Node: ${node.name}] Healthcheck Failed: Container ${c.Names[0]} is unhealthy.`, containerStack);
this.alertedCrashes.set(c.Id, now);
} }
} }
} catch (err) { } catch (err) {
console.error(`Error checking crashes on node ${node.name}`, err); console.error(`Error checking crashes on node ${node.name}`, err);
} }
} }
// Clear crash tracking for containers that are running again
for (const id of this.alertedCrashes.keys()) {
if (runningIds.has(id)) this.alertedCrashes.delete(id);
}
} catch (e) { } catch (e) {
console.error('Error checking global crashes', e); console.error('Error checking global crashes', e);
} }
@@ -187,18 +222,11 @@ export class MonitorService {
} }
const reclaimGb = totalReclaimableBytes / (1024 * 1024 * 1024); const reclaimGb = totalReclaimableBytes / (1024 * 1024 * 1024);
// Only trigger once every while? To avoid spamming, we just check if it's over limit const JANITOR_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
// Let's ensure we only spam once per limit breach. We can use a local static variable.
const LAST_JANITOR_ALERT_KEY = 'last_janitor_alert_timestamp';
const lastAlertRaw = DatabaseService.getInstance().getSystemState(LAST_JANITOR_ALERT_KEY);
const lastAlert = parseInt(lastAlertRaw || '0', 10);
const janitorCooldown = 24 * 60 * 60 * 1000; // 24 hours cooldown for janitor
if (reclaimGb >= janitorLimitGb) { if (reclaimGb >= janitorLimitGb) {
if (Date.now() - lastAlert > janitorCooldown) { await this.dispatchWithCooldown(HOST_ALERT_KEYS.janitor, JANITOR_COOLDOWN_MS, 'info',
await notifier.dispatchAlert('info', `Your system has accumulated ${reclaimGb.toFixed(1)} GB of unused Docker data. Consider using the Janitor tool.`); `Your system has accumulated ${reclaimGb.toFixed(1)} GB of unused Docker data. Consider using the Janitor tool.`);
DatabaseService.getInstance().setSystemState(LAST_JANITOR_ALERT_KEY, Date.now().toString());
}
} }
} }
} catch (e) { } catch (e) {
@@ -224,10 +252,11 @@ export class MonitorService {
const rawStats = await docker.getContainerStatsStream(container.Id); const rawStats = await docker.getContainerStatsStream(container.Id);
const stats = JSON.parse(rawStats); const stats = JSON.parse(rawStats);
const usedMemory = (stats.memory_stats?.usage || 0) - (stats.memory_stats?.stats?.cache || 0);
const metrics = { const metrics = {
cpu_percent: this.calculateCpuPercent(stats), cpu_percent: this.calculateCpuPercent(stats),
memory_percent: this.calculateMemoryPercent(stats), memory_percent: this.calculateMemoryPercent(stats),
memory_mb: (stats.memory_stats?.usage || 0) / (1024 * 1024), memory_mb: Math.max(0, usedMemory) / (1024 * 1024),
net_rx: this.calculateNetwork(stats, 'rx'), net_rx: this.calculateNetwork(stats, 'rx'),
net_tx: this.calculateNetwork(stats, 'tx'), net_tx: this.calculateNetwork(stats, 'tx'),
restart_count: 0 // Simplification since ContainerInfo doesn't have it natively restart_count: 0 // Simplification since ContainerInfo doesn't have it natively
@@ -334,6 +363,19 @@ export class MonitorService {
} }
} }
/** Dispatch an alert only if the cooldown period has elapsed since the last alert for this key. */
private async dispatchWithCooldown(
stateKey: string, cooldownMs: number,
severity: 'info' | 'warning' | 'error', message: string, stack?: string,
): Promise<void> {
const db = DatabaseService.getInstance();
const last = parseInt(db.getSystemState(stateKey) || '0', 10);
if (Date.now() - last > cooldownMs) {
await NotificationService.getInstance().dispatchAlert(severity, message, stack);
db.setSystemState(stateKey, Date.now().toString());
}
}
private calculateCpuPercent(stats: any): number { private calculateCpuPercent(stats: any): number {
let cpuPercent = 0.0; let cpuPercent = 0.0;
if (!stats?.cpu_stats?.cpu_usage || !stats?.precpu_stats?.cpu_usage) return 0.0; if (!stats?.cpu_stats?.cpu_usage || !stats?.precpu_stats?.cpu_usage) return 0.0;
+2 -2
View File
@@ -16,8 +16,8 @@ The top bar provides an at-a-glance health assessment for the active node. Sench
| Status | Meaning | | Status | Meaning |
|--------|---------| |--------|---------|
| **Healthy** | All systems nominal. No resources above warning thresholds, no unread errors. | | **Healthy** | All systems nominal. No resources above warning thresholds, no unread errors. |
| **Degraded** | At least one resource is above 80%, or there are unread error alerts. | | **Degraded** | At least one resource is above 80%, there are exited containers, or there are unread error alerts. |
| **Critical** | At least one resource is above 90%, or there are exited containers with unread errors. | | **Critical** | At least one resource is above 90%, or there are exited containers combined with unread errors. |
The bar also shows the active node name, the number of running containers, and the current alert count. The bar also shows the active node name, the number of running containers, and the current alert count.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 77 KiB

@@ -38,13 +38,13 @@ function deriveHealth(stats: Stats, systemStats: SystemStats | null, notificatio
if (disk >= 90) reasons.push(`Disk at ${disk.toFixed(1)}%`); if (disk >= 90) reasons.push(`Disk at ${disk.toFixed(1)}%`);
else if (disk >= 80) reasons.push(`Disk at ${disk.toFixed(1)}%`); else if (disk >= 80) reasons.push(`Disk at ${disk.toFixed(1)}%`);
if (stats.exited > 0 && unreadErrors > 0) reasons.push(`${stats.exited} exited container${stats.exited !== 1 ? 's' : ''}`); if (stats.exited > 0) reasons.push(`${stats.exited} exited container${stats.exited !== 1 ? 's' : ''}`);
else if (unreadErrors > 0) reasons.push(`${unreadErrors} unread error${unreadErrors !== 1 ? 's' : ''}`); if (unreadErrors > 0) reasons.push(`${unreadErrors} unread error${unreadErrors !== 1 ? 's' : ''}`);
if (cpu >= 90 || ram >= 90 || disk >= 90 || (stats.exited > 0 && unreadErrors > 0)) { if (cpu >= 90 || ram >= 90 || disk >= 90 || (stats.exited > 0 && unreadErrors > 0)) {
return { level: 'critical', reasons }; return { level: 'critical', reasons };
} }
if (cpu >= 80 || ram >= 80 || disk >= 80 || unreadErrors > 0) { if (cpu >= 80 || ram >= 80 || disk >= 80 || stats.exited > 0 || unreadErrors > 0) {
return { level: 'degraded', reasons }; return { level: 'degraded', reasons };
} }
return { level: 'healthy', reasons: ['All systems nominal'] }; return { level: 'healthy', reasons: ['All systems nominal'] };
@@ -65,7 +65,7 @@ export function HealthStatusBar({ stats, systemStats, notifications, activeNodeN
const unreadAlerts = notifications.filter(n => !n.is_read).length; const unreadAlerts = notifications.filter(n => !n.is_read).length;
return ( return (
<Card className="bg-card px-4 py-3"> <Card className="bg-card shadow-card-bevel px-4 py-3">
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
{/* Health badge */} {/* Health badge */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -45,7 +45,7 @@ export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title"> <CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" strokeWidth={1.5} /> <Activity className="w-4 h-4 text-stat-icon" strokeWidth={1.5} />
@@ -59,7 +59,7 @@ export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> <XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, 100]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> <YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, (dataMax: number) => Math.max(100, Math.ceil(dataMax / 10) * 10)]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} /> <ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="cpu" stroke="var(--color-cpu)" fill="var(--color-cpu)" fillOpacity={0.4} /> <Area type="monotone" dataKey="cpu" stroke="var(--color-cpu)" fill="var(--color-cpu)" fillOpacity={0.4} />
</AreaChart> </AreaChart>
@@ -73,7 +73,7 @@ export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title"> <CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" strokeWidth={1.5} /> <Activity className="w-4 h-4 text-stat-icon" strokeWidth={1.5} />
@@ -57,7 +57,7 @@ export function RecentAlerts({ notifications, nodes, onCleared }: RecentAlertsPr
}; };
return ( return (
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-stat-title">Recent Alerts</CardTitle> <CardTitle className="text-sm font-medium text-stat-title">Recent Alerts</CardTitle>
@@ -52,7 +52,7 @@ export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) {
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{/* CPU */} {/* CPU */}
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">CPU</CardTitle> <CardTitle className="text-xs font-medium text-stat-title">CPU</CardTitle>
<Cpu className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} /> <Cpu className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
@@ -69,7 +69,7 @@ export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) {
</Card> </Card>
{/* RAM */} {/* RAM */}
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Memory</CardTitle> <CardTitle className="text-xs font-medium text-stat-title">Memory</CardTitle>
<MemoryStick className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} /> <MemoryStick className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
@@ -86,7 +86,7 @@ export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) {
</Card> </Card>
{/* Disk */} {/* Disk */}
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Disk</CardTitle> <CardTitle className="text-xs font-medium text-stat-title">Disk</CardTitle>
<HardDrive className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} /> <HardDrive className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
@@ -103,7 +103,7 @@ export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) {
</Card> </Card>
{/* Containers */} {/* Containers */}
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Containers</CardTitle> <CardTitle className="text-xs font-medium text-stat-title">Containers</CardTitle>
<Container className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} /> <Container className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
@@ -113,7 +113,7 @@ export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) {
<CursorProvider> <CursorProvider>
<CursorContainer className="inline-flex items-baseline"> <CursorContainer className="inline-flex items-baseline">
<span className="text-2xl font-medium font-mono tabular-nums tracking-tight text-stat-value">{stats.active}</span> <span className="text-2xl font-medium font-mono tabular-nums tracking-tight text-stat-value">{stats.active}</span>
<span className="text-sm text-stat-subtitle ml-1.5">{stats.active === 1 ? 'active' : 'actives'}</span> <span className="text-sm text-stat-subtitle ml-1.5">active</span>
</CursorContainer> </CursorContainer>
<Cursor> <Cursor>
<div className="h-2 w-2 rounded-full bg-brand" /> <div className="h-2 w-2 rounded-full bg-brand" />
@@ -141,7 +141,7 @@ export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) {
</Card> </Card>
{/* Network */} {/* Network */}
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Network</CardTitle> <CardTitle className="text-xs font-medium text-stat-title">Network</CardTitle>
<Network className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} /> <Network className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
@@ -78,7 +78,7 @@ export function StackHealthTable({ stackStatuses, metrics, onNavigateToStack }:
if (Object.keys(stackStatuses).length === 0) { if (Object.keys(stackStatuses).length === 0) {
return ( return (
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardContent className="py-8"> <CardContent className="py-8">
<div className="flex flex-col items-center justify-center gap-2 text-stat-subtitle"> <div className="flex flex-col items-center justify-center gap-2 text-stat-subtitle">
<Layers className="h-8 w-8 text-stat-icon" strokeWidth={1.5} /> <Layers className="h-8 w-8 text-stat-icon" strokeWidth={1.5} />
@@ -90,7 +90,7 @@ export function StackHealthTable({ stackStatuses, metrics, onNavigateToStack }:
} }
return ( return (
<Card className="bg-card"> <Card className="bg-card shadow-card-bevel">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-stat-title">Stack Health</CardTitle> <CardTitle className="text-sm font-medium text-stat-title">Stack Health</CardTitle>