mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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.
This commit is contained in:
@@ -623,7 +623,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||
{/* Master-Detail Layout */}
|
||||
<div class="flex gap-4">
|
||||
{/* Left: Host List */}
|
||||
<Card padding="none" class="w-80 flex-shrink-0 overflow-hidden">
|
||||
<Card padding="none" class="w-72 flex-shrink-0 overflow-hidden">
|
||||
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Docker Hosts</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}</p>
|
||||
@@ -635,28 +635,92 @@ export const DockerHosts: Component<DockerHostsProps> = (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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleHostSelection(host.id)}
|
||||
class={`w-full text-left px-4 py-3 transition-colors ${
|
||||
isSelected()
|
||||
? 'bg-blue-100 dark:bg-blue-900/40'
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/20'
|
||||
}`}
|
||||
class={buttonClass()}
|
||||
style={buttonStyle()}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`text-sm font-medium ${isSelected() ? 'text-blue-900 dark:text-blue-100' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||
{/* Host name and status */}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class={`text-sm font-medium truncate ${isSelected() ? 'text-blue-900 dark:text-blue-100' : 'text-gray-900 dark:text-gray-100'}`} title={host.displayName}>
|
||||
{host.displayName}
|
||||
</span>
|
||||
{renderDockerStatusBadge(host.status)}
|
||||
</div>
|
||||
<Show when={hostAlerts().hasAlerts}>
|
||||
<span
|
||||
class={`text-[9px] px-1.5 py-0.5 rounded font-medium flex-shrink-0 ${
|
||||
hostAlerts().criticalCount > 0
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
|
||||
}`}
|
||||
title={`${hostAlerts().criticalCount} critical, ${hostAlerts().warningCount} warning alerts`}
|
||||
>
|
||||
⚠ {hostAlerts().criticalCount + hostAlerts().warningCount}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
{/* Container count */}
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
<span>{runningCount}/{containerCount} running</span>
|
||||
<Show when={host.lastSeen}>
|
||||
<span>{formatRelativeTime(host.lastSeen!)}</span>
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">{formatRelativeTime(host.lastSeen!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
@@ -777,7 +841,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||
<Card padding="none" class="overflow-hidden">
|
||||
{/* Host Info Header */}
|
||||
<div class="bg-gray-50 dark:bg-gray-900/40 border-b-2 border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-bold text-gray-900 dark:text-gray-100">{host().displayName}</h3>
|
||||
<Show when={host().displayName !== host().hostname}>
|
||||
@@ -788,15 +852,82 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||
{selectedHostContainers().length} {selectedHostContainers().length === 1 ? 'container' : 'containers'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Show when={host().lastSeen}>
|
||||
<span>Updated {formatRelativeTime(host().lastSeen!)}</span>
|
||||
</Show>
|
||||
<div class="flex items-center gap-3">
|
||||
<Show when={host().agentVersion}>
|
||||
<span>Agent {host().agentVersion}</span>
|
||||
<span
|
||||
class={`text-[10px] px-2 py-0.5 rounded font-medium ${
|
||||
host().agentVersion?.includes('dev') || host().agentVersion?.startsWith('0.1')
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
}`}
|
||||
>
|
||||
Agent {host().agentVersion}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={host().lastSeen}>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Updated {formatRelativeTime(host().lastSeen!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host Metrics */}
|
||||
<Show when={host().status?.toLowerCase() === 'online' || host().status?.toLowerCase() === 'running'}>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
{/* CPU */}
|
||||
<div>
|
||||
<div class="text-[10px] text-gray-500 dark:text-gray-400 mb-1">CPU Usage</div>
|
||||
<MetricBar
|
||||
value={(() => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Memory */}
|
||||
<div>
|
||||
<div class="text-[10px] text-gray-500 dark:text-gray-400 mb-1">Memory Usage</div>
|
||||
<MetricBar
|
||||
value={(() => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div>
|
||||
<div class="text-[10px] text-gray-500 dark:text-gray-400 mb-1">Host Uptime</div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{host().uptimeSeconds ? formatUptime(host().uptimeSeconds) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Containers Table */}
|
||||
|
||||
@@ -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<AlertIndicatorProps> = (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<AlertCountBadgeProps> = (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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user