feat(alerts): expose docker update-alert delay with an off state in the UI

Users hit by unwanted container image update alerts (#1545) had no UI
lever short of disabling all container alerts; updateAlertDelayHours
was backend-only. Add a Container update alerts card to the Docker
thresholds tab with an on/off toggle (off saves -1) and an hours delay
input. The save path normalizes the value so 0 (which the backend
treats as unset and resets to 24) is never emitted, and the snapshot
builder carries the field through unrelated saves.

Also fix the docker-specific-controls gate: it required a legacy
'docker-host' typed resource, which hid the ignored-prefixes and swarm
service cards (and would have hidden this one) for unified-agent Docker
hosts that only appear in the runtime list via their app-containers.
The gate now also accepts any non-TrueNAS container runtime.
This commit is contained in:
rcourtman
2026-07-17 14:23:12 +01:00
parent 8830eb66b7
commit f8e8135a32
10 changed files with 228 additions and 1 deletions
@@ -4,6 +4,7 @@ import { ThresholdsTableDockerContainersSection } from './ThresholdsTableDockerC
import { ThresholdsTableDockerHostsSection } from './ThresholdsTableDockerHostsSection'; import { ThresholdsTableDockerHostsSection } from './ThresholdsTableDockerHostsSection';
import { ThresholdsTableDockerIgnoredPrefixesSection } from './ThresholdsTableDockerIgnoredPrefixesSection'; import { ThresholdsTableDockerIgnoredPrefixesSection } from './ThresholdsTableDockerIgnoredPrefixesSection';
import { ThresholdsTableDockerServiceGapSection } from './ThresholdsTableDockerServiceGapSection'; import { ThresholdsTableDockerServiceGapSection } from './ThresholdsTableDockerServiceGapSection';
import { ThresholdsTableDockerUpdateAlertsSection } from './ThresholdsTableDockerUpdateAlertsSection';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps'; import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
export function ThresholdsTableDockerTab(props: ThresholdsTableSectionProps) { export function ThresholdsTableDockerTab(props: ThresholdsTableSectionProps) {
@@ -11,6 +12,7 @@ export function ThresholdsTableDockerTab(props: ThresholdsTableSectionProps) {
<> <>
<Show when={props.state.hasDockerSpecificControls()}> <Show when={props.state.hasDockerSpecificControls()}>
<ThresholdsTableDockerIgnoredPrefixesSection {...props} /> <ThresholdsTableDockerIgnoredPrefixesSection {...props} />
<ThresholdsTableDockerUpdateAlertsSection {...props} />
<ThresholdsTableDockerServiceGapSection {...props} /> <ThresholdsTableDockerServiceGapSection {...props} />
</Show> </Show>
<ThresholdsTableDockerHostsSection {...props} /> <ThresholdsTableDockerHostsSection {...props} />
@@ -0,0 +1,91 @@
import { Show, createSignal } from 'solid-js';
import { Card } from '@/components/shared/Card';
import Toggle from '@/components/shared/Toggle';
import { FACTORY_DOCKER_DEFAULTS } from '@/utils/alertThresholdDefaults';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
export function ThresholdsTableDockerUpdateAlertsSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
const updateAlertsEnabled = () => tableProps.dockerDefaults.updateAlertDelayHours > 0;
// Remember the last positive delay so toggling off and back on restores it.
const [lastDelayHours, setLastDelayHours] = createSignal(
updateAlertsEnabled()
? tableProps.dockerDefaults.updateAlertDelayHours
: FACTORY_DOCKER_DEFAULTS.updateAlertDelayHours,
);
return (
<Card padding="md" tone="card" class="mb-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-sm font-semibold text-base-content">
{state.dockerUpdatePresentation.title}
</h3>
<p class="mt-1 text-xs text-muted">{state.dockerUpdatePresentation.description}</p>
</div>
<Toggle
checked={updateAlertsEnabled()}
onToggle={() => {
if (updateAlertsEnabled()) {
setLastDelayHours(tableProps.dockerDefaults.updateAlertDelayHours);
tableProps.setDockerDefaults((prev) => ({ ...prev, updateAlertDelayHours: -1 }));
} else {
tableProps.setDockerDefaults((prev) => ({
...prev,
updateAlertDelayHours: lastDelayHours(),
}));
}
tableProps.setHasUnsavedChanges(true);
}}
label={
<span class="text-sm font-medium text-base-content">
{state.dockerUpdatePresentation.toggleLabel}
</span>
}
description={
<span class="text-xs text-muted">
{state.dockerUpdatePresentation.toggleDescription}
</span>
}
size="sm"
/>
</div>
<Show when={updateAlertsEnabled()}>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label
for={state.dockerUpdateDelayInputId}
class="text-xs font-medium uppercase tracking-wide text-muted"
>
{state.dockerUpdatePresentation.delayLabel}
</label>
<input
type="number"
min="1"
step="1"
id={state.dockerUpdateDelayInputId}
value={tableProps.dockerDefaults.updateAlertDelayHours}
onInput={(event) => {
const value = Number(event.currentTarget.value);
const normalized = Number.isFinite(value)
? Math.max(1, Math.round(value))
: FACTORY_DOCKER_DEFAULTS.updateAlertDelayHours;
setLastDelayHours(normalized);
tableProps.setDockerDefaults((prev) => ({
...prev,
updateAlertDelayHours: normalized,
}));
tableProps.setHasUnsavedChanges(true);
}}
class="mt-1 w-full rounded-md border border-border bg-surface p-2 text-sm text-base-content focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:focus:border-sky-400 dark:focus:ring-sky-600"
/>
<p class="mt-1 text-xs text-muted">{state.dockerUpdatePresentation.delayDescription}</p>
</div>
</div>
</Show>
</Card>
);
}
@@ -1406,3 +1406,76 @@ describe('cloneBackupDefaults (via createDefaultAlertsConfigurationSnapshot)', (
}); });
}); });
}); });
// ===========================================================================
// dockerDefaults.updateAlertDelayHours (read + save round-trip)
// ===========================================================================
describe('dockerDefaults.updateAlertDelayHours', () => {
it('defaults to 24 when the config omits the field', () => {
const snapshot = readAlertsConfigurationSnapshot(cfg({ dockerDefaults: {} }));
expect(snapshot.dockerDefaults.updateAlertDelayHours).toBe(24);
});
it('preserves a custom positive delay and the -1 off state on read', () => {
const custom = readAlertsConfigurationSnapshot(
cfg({ dockerDefaults: { updateAlertDelayHours: 48 } }),
);
expect(custom.dockerDefaults.updateAlertDelayHours).toBe(48);
const off = readAlertsConfigurationSnapshot(
cfg({ dockerDefaults: { updateAlertDelayHours: -1 } }),
);
expect(off.dockerDefaults.updateAlertDelayHours).toBe(-1);
});
it('maps 0 (backend "unset") to the factory 24 on read', () => {
const snapshot = readAlertsConfigurationSnapshot(
cfg({ dockerDefaults: { updateAlertDelayHours: 0 } }),
);
expect(snapshot.dockerDefaults.updateAlertDelayHours).toBe(24);
});
it('round-trips -1 and custom delays through buildAlertsConfigurationPayload', () => {
for (const value of [-1, 6, 24, 72]) {
const snapshot = createDefaultAlertsConfigurationSnapshot();
snapshot.dockerDefaults.updateAlertDelayHours = value;
const { alertConfig } = buildAlertsConfigurationPayload({
snapshot,
rawOverridesConfig: {},
alertsActivationState: null,
alertsActivationConfig: null,
});
expect(alertConfig?.dockerDefaults?.updateAlertDelayHours).toBe(value);
}
});
it('never emits 0 on save (the backend would reset it to 24)', () => {
const snapshot = createDefaultAlertsConfigurationSnapshot();
snapshot.dockerDefaults.updateAlertDelayHours = 0;
const { alertConfig } = buildAlertsConfigurationPayload({
snapshot,
rawOverridesConfig: {},
alertsActivationState: null,
alertsActivationConfig: null,
});
expect(alertConfig?.dockerDefaults?.updateAlertDelayHours).toBe(24);
});
it('keeps the field when saving unrelated settings (default snapshot save)', () => {
const snapshot = createDefaultAlertsConfigurationSnapshot();
const { alertConfig } = buildAlertsConfigurationPayload({
snapshot,
rawOverridesConfig: {},
alertsActivationState: null,
alertsActivationConfig: null,
});
expect(alertConfig?.dockerDefaults?.updateAlertDelayHours).toBe(24);
});
});
@@ -186,6 +186,20 @@ const normalizeGap = (value: unknown, fallback: number) => {
return Math.max(0, Math.min(100, numeric)); return Math.max(0, Math.min(100, numeric));
}; };
// -1 disables update alerts; the backend treats 0 as unset and resets it to 24,
// so never emit 0. Anything non-finite or non-positive falls back to the factory delay.
const normalizeUpdateAlertDelayHours = (value: unknown): number => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return FACTORY_DOCKER_DEFAULTS.updateAlertDelayHours;
}
if (numeric < 0) {
return -1;
}
const rounded = Math.round(numeric);
return rounded > 0 ? rounded : FACTORY_DOCKER_DEFAULTS.updateAlertDelayHours;
};
const normalizeWarningCriticalPair = ( const normalizeWarningCriticalPair = (
warning: number | undefined, warning: number | undefined,
critical: number | undefined, critical: number | undefined,
@@ -445,6 +459,9 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf
config.dockerDefaults.memoryCriticalPct ?? FACTORY_DOCKER_DEFAULTS.memoryCriticalPct, config.dockerDefaults.memoryCriticalPct ?? FACTORY_DOCKER_DEFAULTS.memoryCriticalPct,
serviceWarnGapPercent: serviceWarnGap, serviceWarnGapPercent: serviceWarnGap,
serviceCriticalGapPercent: serviceCriticalGap, serviceCriticalGapPercent: serviceCriticalGap,
updateAlertDelayHours: normalizeUpdateAlertDelayHours(
config.dockerDefaults.updateAlertDelayHours,
),
}; };
snapshot.dockerDisableConnectivity = Boolean(config.dockerDefaults.stateDisableConnectivity); snapshot.dockerDisableConnectivity = Boolean(config.dockerDefaults.stateDisableConnectivity);
snapshot.dockerPoweredOffSeverity = snapshot.dockerPoweredOffSeverity =
@@ -772,6 +789,9 @@ export function buildAlertsConfigurationPayload({
memoryCriticalPct: snapshot.dockerDefaults.memoryCriticalPct, memoryCriticalPct: snapshot.dockerDefaults.memoryCriticalPct,
serviceWarnGapPercent: snapshot.dockerDefaults.serviceWarnGapPercent, serviceWarnGapPercent: snapshot.dockerDefaults.serviceWarnGapPercent,
serviceCriticalGapPercent: snapshot.dockerDefaults.serviceCriticalGapPercent, serviceCriticalGapPercent: snapshot.dockerDefaults.serviceCriticalGapPercent,
updateAlertDelayHours: normalizeUpdateAlertDelayHours(
snapshot.dockerDefaults.updateAlertDelayHours,
),
stateDisableConnectivity: snapshot.dockerDisableConnectivity, stateDisableConnectivity: snapshot.dockerDisableConnectivity,
statePoweredOffSeverity: snapshot.dockerPoweredOffSeverity, statePoweredOffSeverity: snapshot.dockerPoweredOffSeverity,
}, },
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from '@solidjs/router';
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts'; import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation'; import { useAlertsActivation } from '@/stores/alertsActivation';
import { isTrueNASSystemResource } from '@/utils/agentResources';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { import {
AGENT_DISKS_EMPTY_STATE, AGENT_DISKS_EMPTY_STATE,
@@ -13,6 +14,7 @@ import {
getAlertThresholdsHelpDismissLabel, getAlertThresholdsHelpDismissLabel,
getAlertThresholdsBackupOrphanedPresentation, getAlertThresholdsBackupOrphanedPresentation,
getAlertThresholdsDockerServicePresentation, getAlertThresholdsDockerServicePresentation,
getAlertThresholdsDockerUpdatePresentation,
getAlertThresholdsDockerIgnoredPrefixesPresentation, getAlertThresholdsDockerIgnoredPrefixesPresentation,
getAlertThresholdsSearchPlaceholder, getAlertThresholdsSearchPlaceholder,
getAlertThresholdsSectionTitles, getAlertThresholdsSectionTitles,
@@ -87,6 +89,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const guestFilterPresentation = getAlertThresholdsGuestFilterPresentation(); const guestFilterPresentation = getAlertThresholdsGuestFilterPresentation();
const backupOrphanedPresentation = getAlertThresholdsBackupOrphanedPresentation(); const backupOrphanedPresentation = getAlertThresholdsBackupOrphanedPresentation();
const dockerServicePresentation = getAlertThresholdsDockerServicePresentation(); const dockerServicePresentation = getAlertThresholdsDockerServicePresentation();
const dockerUpdatePresentation = getAlertThresholdsDockerUpdatePresentation();
const dockerIgnoredPrefixesPresentation = getAlertThresholdsDockerIgnoredPrefixesPresentation(); const dockerIgnoredPrefixesPresentation = getAlertThresholdsDockerIgnoredPrefixesPresentation();
const { isCollapsed, toggleSection, setCollapsed, expandAll, collapseAll } = const { isCollapsed, toggleSection, setCollapsed, expandAll, collapseAll } =
@@ -120,6 +123,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const serviceWarnInputId = 'docker-service-warn-gap'; const serviceWarnInputId = 'docker-service-warn-gap';
const serviceCriticalInputId = 'docker-service-critical-gap'; const serviceCriticalInputId = 'docker-service-critical-gap';
const dockerUpdateDelayInputId = 'docker-update-alert-delay';
createEffect(() => { createEffect(() => {
const remote = props.dockerIgnoredPrefixes(); const remote = props.dockerIgnoredPrefixes();
@@ -143,7 +147,14 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
return ''; return '';
}); });
const hasDockerSpecificControls = createMemo(() => (props.dockerHosts?.length ?? 0) > 0); // Docker-only cards (ignored prefixes, update alerts, swarm gaps) stay hidden for
// TrueNAS-only setups, but must show for unified-agent Docker hosts, which are not
// typed 'docker-host' and only appear in the runtime list via their app-containers.
const hasDockerSpecificControls = createMemo(
() =>
(props.dockerHosts?.length ?? 0) > 0 ||
(props.containerRuntimes ?? []).some((runtime) => !isTrueNASSystemResource(runtime)),
);
const getActiveTabFromRoute = (): ThresholdsActiveTab => { const getActiveTabFromRoute = (): ThresholdsActiveTab => {
const path = location.pathname; const path = location.pathname;
@@ -848,6 +859,8 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
dockerIgnoredInput, dockerIgnoredInput,
dockerIgnoredPrefixesPresentation, dockerIgnoredPrefixesPresentation,
dockerServicePresentation, dockerServicePresentation,
dockerUpdatePresentation,
dockerUpdateDelayInputId,
editingId, editingId,
editingNote, editingNote,
editingThresholds, editingThresholds,
@@ -156,6 +156,7 @@ export interface ThresholdsTableProps {
memoryCriticalPct: number; memoryCriticalPct: number;
serviceWarnGapPercent: number; serviceWarnGapPercent: number;
serviceCriticalGapPercent: number; serviceCriticalGapPercent: number;
updateAlertDelayHours: number;
}; };
dockerDisableConnectivity: () => boolean; dockerDisableConnectivity: () => boolean;
setDockerDisableConnectivity: (value: boolean) => void; setDockerDisableConnectivity: (value: boolean) => void;
@@ -173,6 +174,7 @@ export interface ThresholdsTableProps {
memoryCriticalPct: number; memoryCriticalPct: number;
serviceWarnGapPercent: number; serviceWarnGapPercent: number;
serviceCriticalGapPercent: number; serviceCriticalGapPercent: number;
updateAlertDelayHours: number;
} }
| ((prev: { | ((prev: {
cpu: number; cpu: number;
@@ -184,6 +186,7 @@ export interface ThresholdsTableProps {
memoryCriticalPct: number; memoryCriticalPct: number;
serviceWarnGapPercent: number; serviceWarnGapPercent: number;
serviceCriticalGapPercent: number; serviceCriticalGapPercent: number;
updateAlertDelayHours: number;
}) => { }) => {
cpu: number; cpu: number;
memory: number; memory: number;
@@ -194,6 +197,7 @@ export interface ThresholdsTableProps {
memoryCriticalPct: number; memoryCriticalPct: number;
serviceWarnGapPercent: number; serviceWarnGapPercent: number;
serviceCriticalGapPercent: number; serviceCriticalGapPercent: number;
updateAlertDelayHours: number;
}), }),
) => void; ) => void;
dockerIgnoredPrefixes: () => string[]; dockerIgnoredPrefixes: () => string[];
+1
View File
@@ -71,6 +71,7 @@ export interface DockerThresholdConfig {
memoryCriticalPct?: number; memoryCriticalPct?: number;
serviceWarnGapPercent?: number; serviceWarnGapPercent?: number;
serviceCriticalGapPercent?: number; serviceCriticalGapPercent?: number;
updateAlertDelayHours?: number;
stateDisableConnectivity?: boolean; stateDisableConnectivity?: boolean;
statePoweredOffSeverity?: 'warning' | 'critical'; statePoweredOffSeverity?: 'warning' | 'critical';
} }
@@ -162,6 +162,7 @@ describe('alertThresholdDefaults', () => {
memoryCriticalPct: 95, memoryCriticalPct: 95,
serviceWarnGapPercent: 10, serviceWarnGapPercent: 10,
serviceCriticalGapPercent: 50, serviceCriticalGapPercent: 50,
updateAlertDelayHours: 24,
}); });
}); });
@@ -83,6 +83,8 @@ export const FACTORY_DOCKER_DEFAULTS = {
memoryCriticalPct: 95, memoryCriticalPct: 95,
serviceWarnGapPercent: 10, serviceWarnGapPercent: 10,
serviceCriticalGapPercent: 50, serviceCriticalGapPercent: 50,
// Hours before an available image update raises an alert; -1 disables update alerts.
updateAlertDelayHours: 24,
}; };
export const FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY = false; export const FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY = false;
@@ -63,6 +63,15 @@ export const ALERT_THRESHOLDS_DOCKER_SERVICES_CRITICAL_GAP_DESCRIPTION =
'Raise a critical alert when the missing replica gap meets or exceeds this value.'; 'Raise a critical alert when the missing replica gap meets or exceeds this value.';
export const ALERT_THRESHOLDS_DOCKER_SERVICES_GAP_VALIDATION_MESSAGE = export const ALERT_THRESHOLDS_DOCKER_SERVICES_GAP_VALIDATION_MESSAGE =
'Critical gap must be greater than or equal to the warning gap when enabled.'; 'Critical gap must be greater than or equal to the warning gap when enabled.';
export const ALERT_THRESHOLDS_DOCKER_UPDATES_TITLE = 'Container update alerts';
export const ALERT_THRESHOLDS_DOCKER_UPDATES_DESCRIPTION =
'Pulse raises a warning when a container image update has been available for longer than the delay below. Turn this off to stop update alerts without affecting other container alerts.';
export const ALERT_THRESHOLDS_DOCKER_UPDATES_TOGGLE_LABEL = 'Alerts';
export const ALERT_THRESHOLDS_DOCKER_UPDATES_TOGGLE_DESCRIPTION =
'Toggle container image update alerts';
export const ALERT_THRESHOLDS_DOCKER_UPDATES_DELAY_LABEL = 'Delay (hours)';
export const ALERT_THRESHOLDS_DOCKER_UPDATES_DELAY_DESCRIPTION =
'Wait this many hours after an update is first seen before alerting.';
export const ALERT_THRESHOLDS_SECTION_TITLE_NODES = 'Virtualization Hosts'; export const ALERT_THRESHOLDS_SECTION_TITLE_NODES = 'Virtualization Hosts';
export const ALERT_THRESHOLDS_SECTION_TITLE_PBS = 'PBS Servers'; export const ALERT_THRESHOLDS_SECTION_TITLE_PBS = 'PBS Servers';
export const ALERT_THRESHOLDS_SECTION_TITLE_GUESTS = 'VMs & Containers'; export const ALERT_THRESHOLDS_SECTION_TITLE_GUESTS = 'VMs & Containers';
@@ -149,6 +158,17 @@ export function getAlertThresholdsDockerServicePresentation() {
} as const; } as const;
} }
export function getAlertThresholdsDockerUpdatePresentation() {
return {
title: ALERT_THRESHOLDS_DOCKER_UPDATES_TITLE,
description: ALERT_THRESHOLDS_DOCKER_UPDATES_DESCRIPTION,
toggleLabel: ALERT_THRESHOLDS_DOCKER_UPDATES_TOGGLE_LABEL,
toggleDescription: ALERT_THRESHOLDS_DOCKER_UPDATES_TOGGLE_DESCRIPTION,
delayLabel: ALERT_THRESHOLDS_DOCKER_UPDATES_DELAY_LABEL,
delayDescription: ALERT_THRESHOLDS_DOCKER_UPDATES_DELAY_DESCRIPTION,
} as const;
}
export function getAlertThresholdsSectionTitles() { export function getAlertThresholdsSectionTitles() {
return { return {
nodes: ALERT_THRESHOLDS_SECTION_TITLE_NODES, nodes: ALERT_THRESHOLDS_SECTION_TITLE_NODES,