mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
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:
@@ -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'],
|
||||||
}]);
|
}]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user