From 72aa60107d1bf97aaca8cc428487b72cf154178d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 11 Oct 2025 20:44:28 +0000 Subject: [PATCH] Enhance Docker page with simplified sidebar and focused metrics Simplified host sidebar to show only essential monitoring info (name, status, alerts, container count). Moved detailed metrics (CPU, memory, uptime) to host detail view when selected to eliminate duplication. Added alert highlighting with visual indicators for hosts with container alerts. Fixed double 'v' prefix in agent version display. --- .../src/components/Docker/DockerHosts.tsx | 165 ++++++++++++++++-- .../src/components/shared/AlertIndicators.tsx | 70 +++++++- internal/alerts/alerts.go | 8 + 3 files changed, 224 insertions(+), 19 deletions(-) diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index a7f6557c9..e94888a64 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -623,7 +623,7 @@ export const DockerHosts: Component = (props) => { {/* Master-Detail Layout */}
{/* Left: Host List */} - +

Docker Hosts

{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}

@@ -635,28 +635,92 @@ export const DockerHosts: Component = (props) => { const containerCount = (host.containers || []).length; const runningCount = (host.containers || []).filter(c => c.state?.toLowerCase() === 'running').length; + // Check for alerts on this host's containers + const hostAlerts = createMemo(() => { + if (!props.activeAlerts) return { hasAlerts: false, criticalCount: 0, warningCount: 0 }; + + const containers = host.containers || []; + let criticalCount = 0; + let warningCount = 0; + + containers.forEach(container => { + const resourceId = `docker:${host.id}/${container.id}`; + try { + const alertsObj = typeof props.activeAlerts === 'object' ? { ...props.activeAlerts } : props.activeAlerts; + const alerts = Object.values(alertsObj).filter((alert: any) => alert?.resourceId === resourceId); + alerts.forEach((alert: any) => { + if (alert.level === 'critical') criticalCount++; + else if (alert.level === 'warning') warningCount++; + }); + } catch (e) { + // Ignore errors + } + }); + + return { hasAlerts: criticalCount > 0 || warningCount > 0, criticalCount, warningCount }; + }); + + const buttonClass = () => { + const alerts = hostAlerts(); + let base = 'w-full text-left px-4 py-2.5 transition-all duration-200 relative'; + + if (isSelected()) { + base += ' bg-blue-100 dark:bg-blue-900/40'; + } else if (alerts.criticalCount > 0) { + base += ' bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40'; + } else if (alerts.warningCount > 0) { + base += ' bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30'; + } else { + base += ' hover:bg-blue-50 dark:hover:bg-blue-900/20'; + } + + return base; + }; + + const buttonStyle = () => { + const alerts = hostAlerts(); + if (!alerts.hasAlerts) return {}; + + const color = alerts.criticalCount > 0 ? '#ef4444' : '#eab308'; + return { + 'box-shadow': `inset 4px 0 0 0 ${color}`, + }; + }; + return ( @@ -777,7 +841,7 @@ export const DockerHosts: Component = (props) => { {/* Host Info Header */}
-
+

{host().displayName}

@@ -788,15 +852,82 @@ export const DockerHosts: Component = (props) => { {selectedHostContainers().length} {selectedHostContainers().length === 1 ? 'container' : 'containers'}
-
- - Updated {formatRelativeTime(host().lastSeen!)} - +
- Agent {host().agentVersion} + + Agent {host().agentVersion} + + + + Updated {formatRelativeTime(host().lastSeen!)}
+ + {/* Host Metrics */} + +
+ {/* CPU */} +
+
CPU Usage
+ { + const total = (host().containers || []) + .filter(c => c.state?.toLowerCase() === 'running') + .reduce((sum, c) => sum + (c.cpuPercent || 0), 0); + return Math.min(100, Math.max(0, total)); + })()} + label={`${(() => { + const total = (host().containers || []) + .filter(c => c.state?.toLowerCase() === 'running') + .reduce((sum, c) => sum + (c.cpuPercent || 0), 0); + return Math.min(100, Math.max(0, total)).toFixed(0); + })()}%`} + type="cpu" + /> +
+ + {/* Memory */} +
+
Memory Usage
+ { + if (!host().totalMemoryBytes) return 0; + const usedBytes = (host().containers || []) + .reduce((sum, c) => sum + (c.memoryUsageBytes || 0), 0); + return Math.min(100, Math.max(0, (usedBytes / host().totalMemoryBytes) * 100)); + })()} + label={`${(() => { + if (!host().totalMemoryBytes) return '0'; + const usedBytes = (host().containers || []) + .reduce((sum, c) => sum + (c.memoryUsageBytes || 0), 0); + return Math.min(100, Math.max(0, (usedBytes / host().totalMemoryBytes) * 100)).toFixed(0); + })()}%`} + sublabel={(() => { + if (!host().totalMemoryBytes) return undefined; + const usedBytes = (host().containers || []) + .reduce((sum, c) => sum + (c.memoryUsageBytes || 0), 0); + return `${formatBytes(usedBytes)}/${formatBytes(host().totalMemoryBytes)}`; + })()} + type="memory" + /> +
+ + {/* Uptime */} +
+
Host Uptime
+
+ {host().uptimeSeconds ? formatUptime(host().uptimeSeconds) : '—'} +
+
+
+
{/* Containers Table */} diff --git a/frontend-modern/src/components/shared/AlertIndicators.tsx b/frontend-modern/src/components/shared/AlertIndicators.tsx index f8bfa8200..40298602d 100644 --- a/frontend-modern/src/components/shared/AlertIndicators.tsx +++ b/frontend-modern/src/components/shared/AlertIndicators.tsx @@ -2,6 +2,67 @@ import { Component } from 'solid-js'; import type { Alert } from '@/types/api'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; +const getMetadataUnit = (alert: Alert): string | undefined => { + const rawUnit = alert.metadata?.['unit']; + if (typeof rawUnit === 'string') { + const trimmed = rawUnit.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return undefined; +}; + +const formatAlertValue = (alert: Alert): string => { + const metric = alert.type.toLowerCase(); + const unitFromMetadata = getMetadataUnit(alert); + + switch (metric) { + case 'temperature': + return `${alert.value.toFixed(1)}°C`; + case 'diskread': + case 'diskwrite': + case 'networkin': + case 'networkout': + return `${alert.value.toFixed(1)} MB/s`; + case 'cpu': + case 'memory': + case 'disk': + case 'usage': + return `${alert.value.toFixed(1)}%`; + default: + if (unitFromMetadata) { + return `${alert.value.toFixed(1)} ${unitFromMetadata}`; + } + return alert.value.toFixed(1); + } +}; + +const formatAlertThreshold = (alert: Alert): string => { + const metric = alert.type.toLowerCase(); + const unitFromMetadata = getMetadataUnit(alert); + + switch (metric) { + case 'temperature': + return `${alert.threshold.toFixed(0)}°C`; + case 'diskread': + case 'diskwrite': + case 'networkin': + case 'networkout': + return `${alert.threshold.toFixed(0)} MB/s`; + case 'cpu': + case 'memory': + case 'disk': + case 'usage': + return `${alert.threshold.toFixed(0)}%`; + default: + if (unitFromMetadata) { + return `${alert.threshold.toFixed(0)} ${unitFromMetadata}`; + } + return alert.threshold.toFixed(0); + } +}; + interface AlertIndicatorProps { severity: 'critical' | 'warning' | null; alerts?: Alert[]; @@ -16,7 +77,9 @@ export const AlertIndicator: Component = (props) => { if (!props.alerts || props.alerts.length === 0) return; const rect = (e.target as HTMLElement).getBoundingClientRect(); const content = props.alerts - .map((alert) => `${alert.type}: ${alert.value.toFixed(1)}% (threshold: ${alert.threshold}%)`) + .map( + (alert) => `${alert.type}: ${formatAlertValue(alert)} (threshold: ${formatAlertThreshold(alert)})`, + ) .join('\n'); showTooltip(content, rect.left + rect.width / 2, rect.top, { align: 'center', @@ -55,7 +118,10 @@ export const AlertCountBadge: Component = (props) => { const rect = (e.target as HTMLElement).getBoundingClientRect(); const header = `${props.count} Active Alert${props.count === 1 ? '' : 's'}:`; const details = props.alerts - .map((alert, index) => `${index + 1}. ${alert.type}: ${alert.value.toFixed(1)}% (threshold: ${alert.threshold}%)`) + .map( + (alert, index) => + `${index + 1}. ${alert.type}: ${formatAlertValue(alert)} (threshold: ${formatAlertThreshold(alert)})`, + ) .join('\n'); const content = [header, details].filter(Boolean).join('\n'); showTooltip(content, rect.left + rect.width / 2, rect.top, { diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 424c24311..a84b86868 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -3090,6 +3090,7 @@ func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resource // New alert message := "" + var unit string if opts != nil && opts.Message != "" { message = opts.Message } else { @@ -3098,6 +3099,10 @@ func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resource message = fmt.Sprintf("%s at %.1f%%", resourceType, value) case "diskRead", "diskWrite", "networkIn", "networkOut": message = fmt.Sprintf("%s %s at %.1f MB/s", resourceType, metricType, value) + unit = "MB/s" + case "temperature": + message = fmt.Sprintf("%s %s at %.1f°C", resourceType, metricType, value) + unit = "°C" default: message = fmt.Sprintf("%s %s at %.1f%%", resourceType, metricType, value) } @@ -3107,6 +3112,9 @@ func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resource "resourceType": resourceType, "clearThreshold": threshold.Clear, } + if unit != "" { + alertMetadata["unit"] = unit + } if opts != nil && opts.Metadata != nil { for k, v := range opts.Metadata { alertMetadata[k] = v