feat(alerts): clean alert messages + highlight all platform node tables

Backend:
- Map internal resource types to human-readable labels in alert messages
  ('agent-disk disk at 95.8%' -> 'Disk at 95.8%')
- Fix redundant Unraid message ('running check' -> 'check in progress')

Frontend:
- Add alert row highlighting to Docker, Kubernetes, TrueNAS, and vSphere
  host/system tables (same pattern as the Proxmox nodes table fix)
This commit is contained in:
rcourtman 2026-06-27 14:30:41 +01:00
parent 5fc30e2060
commit fa5a874986
10 changed files with 131 additions and 11 deletions

View file

@ -7,6 +7,9 @@ import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getAlertStyles } from '@/utils/alerts';
import { useWebSocket } from '@/contexts/appRuntime';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { asTrimmedString } from '@/utils/stringUtils';
import { normalizeDiskArray } from '@/utils/format';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
@ -86,6 +89,9 @@ export const DockerHostsTable: Component<{
title?: string;
showToolbar?: boolean;
}> = (props) => {
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as DockerResourceStatusFilter,
@ -250,11 +256,21 @@ export const DockerHostsTable: Component<{
event.preventDefault();
toggleDrawer();
};
const hostAlertStyles = createMemo(() =>
getAlertStyles(host.id, activeAlerts, alertsEnabled(), name()),
);
const hostAlertBg = () => {
const s = hostAlertStyles();
if (!s.hasUnacknowledgedAlert) return '';
return s.severity === 'critical'
? 'bg-red-50 dark:bg-red-950'
: 'bg-yellow-50 dark:bg-yellow-950';
};
return (
<>
<TableRow
class={`cursor-pointer text-[11px] outline-none sm:text-xs ${
isSelected() ? 'bg-surface-hover' : ''
isSelected() ? 'bg-surface-hover' : hostAlertBg()
} focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface`}
aria-controls={isSelected() ? detailRowId() : undefined}
aria-expanded={isSelected() ? 'true' : 'false'}

View file

@ -15,6 +15,13 @@ vi.mock('@/components/shared/responsive', () => ({
),
}));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts: {} as Record<string, never> }),
}));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({ activationState: () => 'active' }),
}));
vi.mock('@/components/Workloads/StackedMemoryBar', () => ({
StackedMemoryBar: (props: { used: number; total: number; percentOnly?: number }) => (
<div

View file

@ -4,6 +4,9 @@ import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import { getAlertStyles } from '@/utils/alerts';
import { useWebSocket } from '@/contexts/appRuntime';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
@ -60,6 +63,9 @@ export const KubernetesNodesTable: Component<{
title?: string;
showToolbar?: boolean;
}> = (props) => {
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@ -202,10 +208,20 @@ export const KubernetesNodesTable: Component<{
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(node);
const isExpanded = () => drawer.isExpanded(node);
const nodeAlertStyles = createMemo(() =>
getAlertStyles(node.id, activeAlerts, alertsEnabled(), name()),
);
const nodeAlertBg = () => {
const s = nodeAlertStyles();
if (!s.hasUnacknowledgedAlert) return '';
return s.severity === 'critical'
? 'bg-red-50 dark:bg-red-950'
: 'bg-yellow-50 dark:bg-yellow-950';
};
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs ${nodeAlertBg()}`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-kubernetes-node-row={node.id}

View file

@ -39,6 +39,16 @@ vi.mock('@/components/Workloads/StackedDiskBar', () => ({
StackedDiskBar: () => <div data-testid="stacked-disk-bar" />,
}));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts: {} as Record<string, never> }),
}));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({
activationState: () => 'active',
getMetricThresholds: () => ({}),
}),
}));
const makeResource = ({
id,
type,

View file

@ -4,6 +4,9 @@ import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getAlertStyles } from '@/utils/alerts';
import { useWebSocket } from '@/contexts/appRuntime';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
@ -86,6 +89,9 @@ export const TrueNASSystemsTable: Component<{
title?: string;
showToolbar?: boolean;
}> = (props) => {
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const tableState = createPlatformTableFilterState({
resources: () => props.systems,
initialStatus: 'all' as PlatformResourceStatusFilter,
@ -225,10 +231,20 @@ export const TrueNASSystemsTable: Component<{
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(system);
const isExpanded = () => drawer.isExpanded(system);
const sysAlertStyles = createMemo(() =>
getAlertStyles(system.id, activeAlerts, alertsEnabled(), name()),
);
const sysAlertBg = () => {
const s = sysAlertStyles();
if (!s.hasUnacknowledgedAlert) return '';
return s.severity === 'critical'
? 'bg-red-50 dark:bg-red-950'
: 'bg-yellow-50 dark:bg-yellow-950';
};
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs ${sysAlertBg()}`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-truenas-system-row={system.id}

View file

@ -9,6 +9,13 @@ vi.mock('@/components/shared/responsive', () => ({
ResponsiveMetricCell: () => <div data-testid="responsive-metric-cell" />,
}));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts: {} as Record<string, never> }),
}));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({ activationState: () => 'active' }),
}));
import { TrueNASSystemsTable } from '@/features/truenas/TrueNASSystemsTable';
import type { Resource } from '@/types/resource';

View file

@ -4,6 +4,9 @@ import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getAlertStyles } from '@/utils/alerts';
import { useWebSocket } from '@/contexts/appRuntime';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { asTrimmedString } from '@/utils/stringUtils';
import { formatVmwareClusterServices } from '@/utils/vmwareDisplay';
import {
@ -58,6 +61,9 @@ export const VsphereHostsTable: Component<{
title?: string;
showToolbar?: boolean;
}> = (props) => {
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const tableState = createPlatformTableFilterState({
resources: () => props.hosts,
initialStatus: 'all' as PlatformResourceStatusFilter,
@ -217,10 +223,20 @@ export const VsphereHostsTable: Component<{
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(host);
const isExpanded = () => drawer.isExpanded(host);
const hostAlertStyles = createMemo(() =>
getAlertStyles(host.id, activeAlerts, alertsEnabled(), name()),
);
const hostAlertBg = () => {
const s = hostAlertStyles();
if (!s.hasUnacknowledgedAlert) return '';
return s.severity === 'critical'
? 'bg-red-50 dark:bg-red-950'
: 'bg-yellow-50 dark:bg-yellow-950';
};
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs ${hostAlertBg()}`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-vsphere-host-row={host.id}

View file

@ -9,6 +9,13 @@ vi.mock('@/components/shared/responsive', () => ({
ResponsiveMetricCell: () => <div data-testid="responsive-metric-cell" />,
}));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts: {} as Record<string, never> }),
}));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({ activationState: () => 'active' }),
}));
import { VsphereHostsTable } from '@/features/vmware/VsphereHostsTable';
import type { Resource } from '@/types/resource';

View file

@ -2,6 +2,7 @@ package alerts
import (
"fmt"
"strings"
"time"
alertspecs "github.com/rcourtman/pulse-go-rewrite/internal/alerts/specs"
@ -44,20 +45,44 @@ func metricClearThreshold(spec *alertspecs.MetricThresholdSpec, threshold *Hyste
return 0
}
func resourceTypeLabel(resourceType string) string {
switch strings.TrimSpace(resourceType) {
case "agent-disk":
return "Disk"
case "agent":
return "Agent"
case "node":
return "Node"
case "guest":
return "Guest"
case "storage":
return "Storage"
case "pbs":
return "PBS"
case "pmg":
return "PMG"
case "":
return "Resource"
default:
return resourceType
}
}
func metricAlertMessage(resourceType, metricType string, value float64, opts *metricOptions) (string, string) {
if opts != nil && opts.Message != "" {
return opts.Message, ""
}
label := resourceTypeLabel(resourceType)
switch metricType {
case "usage":
return resourceType + " at " + formatMetricValue(value, "%"), "%"
case "usage", "disk":
return label + " at " + formatMetricValue(value, "%"), "%"
case "diskRead", "diskWrite", "networkIn", "networkOut":
return resourceType + " " + metricType + " at " + formatMetricValue(value, " MB/s"), "MB/s"
return label + " " + metricType + " at " + formatMetricValue(value, " MB/s"), "MB/s"
case "temperature", "disk_temperature", "diskTemperature":
return resourceType + " " + metricType + " at " + formatMetricValue(value, "°C"), "°C"
return label + " " + metricType + " at " + formatMetricValue(value, "°C"), "°C"
default:
return resourceType + " " + metricType + " at " + formatMetricValue(value, "%"), ""
return label + " " + metricType + " at " + formatMetricValue(value, "%"), ""
}
}

View file

@ -229,9 +229,9 @@ func AssessUnraidStorage(storage models.HostUnraidStorage) Assessment {
}
if action := strings.TrimSpace(storage.SyncAction); action != "" {
summary := fmt.Sprintf("Unraid array is running %s", action)
summary := fmt.Sprintf("array %s in progress", action)
if storage.SyncProgress > 0 {
summary = fmt.Sprintf("Unraid array is running %s (%.0f%%)", action, storage.SyncProgress)
summary = fmt.Sprintf("array %s in progress (%.0f%%)", action, storage.SyncProgress)
}
addReason("unraid_sync_active", RiskWarning, summary)
}