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