diff --git a/frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx b/frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx index bb61699123..9bd6aad526 100644 --- a/frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx +++ b/frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx @@ -4,6 +4,7 @@ import { ThresholdsTableDockerContainersSection } from './ThresholdsTableDockerC import { ThresholdsTableDockerHostsSection } from './ThresholdsTableDockerHostsSection'; import { ThresholdsTableDockerIgnoredPrefixesSection } from './ThresholdsTableDockerIgnoredPrefixesSection'; import { ThresholdsTableDockerServiceGapSection } from './ThresholdsTableDockerServiceGapSection'; +import { ThresholdsTableDockerUpdateAlertsSection } from './ThresholdsTableDockerUpdateAlertsSection'; import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps'; export function ThresholdsTableDockerTab(props: ThresholdsTableSectionProps) { @@ -11,6 +12,7 @@ export function ThresholdsTableDockerTab(props: ThresholdsTableSectionProps) { <> + diff --git a/frontend-modern/src/components/Alerts/ThresholdsTableDockerUpdateAlertsSection.tsx b/frontend-modern/src/components/Alerts/ThresholdsTableDockerUpdateAlertsSection.tsx new file mode 100644 index 0000000000..f4c9513859 --- /dev/null +++ b/frontend-modern/src/components/Alerts/ThresholdsTableDockerUpdateAlertsSection.tsx @@ -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 ( + +
+
+

+ {state.dockerUpdatePresentation.title} +

+

{state.dockerUpdatePresentation.description}

+
+ { + if (updateAlertsEnabled()) { + setLastDelayHours(tableProps.dockerDefaults.updateAlertDelayHours); + tableProps.setDockerDefaults((prev) => ({ ...prev, updateAlertDelayHours: -1 })); + } else { + tableProps.setDockerDefaults((prev) => ({ + ...prev, + updateAlertDelayHours: lastDelayHours(), + })); + } + tableProps.setHasUnsavedChanges(true); + }} + label={ + + {state.dockerUpdatePresentation.toggleLabel} + + } + description={ + + {state.dockerUpdatePresentation.toggleDescription} + + } + size="sm" + /> +
+ + +
+
+ + { + 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" + /> +

{state.dockerUpdatePresentation.delayDescription}

+
+
+
+
+ ); +} diff --git a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts index 675d9653b2..86ee60069c 100644 --- a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts +++ b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts @@ -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); + }); +}); diff --git a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts index 26b8f3345f..1e415fa999 100644 --- a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts +++ b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts @@ -186,6 +186,20 @@ const normalizeGap = (value: unknown, fallback: number) => { 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 = ( warning: number | undefined, critical: number | undefined, @@ -445,6 +459,9 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf config.dockerDefaults.memoryCriticalPct ?? FACTORY_DOCKER_DEFAULTS.memoryCriticalPct, serviceWarnGapPercent: serviceWarnGap, serviceCriticalGapPercent: serviceCriticalGap, + updateAlertDelayHours: normalizeUpdateAlertDelayHours( + config.dockerDefaults.updateAlertDelayHours, + ), }; snapshot.dockerDisableConnectivity = Boolean(config.dockerDefaults.stateDisableConnectivity); snapshot.dockerPoweredOffSeverity = @@ -772,6 +789,9 @@ export function buildAlertsConfigurationPayload({ memoryCriticalPct: snapshot.dockerDefaults.memoryCriticalPct, serviceWarnGapPercent: snapshot.dockerDefaults.serviceWarnGapPercent, serviceCriticalGapPercent: snapshot.dockerDefaults.serviceCriticalGapPercent, + updateAlertDelayHours: normalizeUpdateAlertDelayHours( + snapshot.dockerDefaults.updateAlertDelayHours, + ), stateDisableConnectivity: snapshot.dockerDisableConnectivity, statePoweredOffSeverity: snapshot.dockerPoweredOffSeverity, }, diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts index 16c68586cf..4637f97e53 100644 --- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from '@solidjs/router'; import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts'; import { useAlertsActivation } from '@/stores/alertsActivation'; +import { isTrueNASSystemResource } from '@/utils/agentResources'; import { logger } from '@/utils/logger'; import { AGENT_DISKS_EMPTY_STATE, @@ -13,6 +14,7 @@ import { getAlertThresholdsHelpDismissLabel, getAlertThresholdsBackupOrphanedPresentation, getAlertThresholdsDockerServicePresentation, + getAlertThresholdsDockerUpdatePresentation, getAlertThresholdsDockerIgnoredPrefixesPresentation, getAlertThresholdsSearchPlaceholder, getAlertThresholdsSectionTitles, @@ -87,6 +89,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) { const guestFilterPresentation = getAlertThresholdsGuestFilterPresentation(); const backupOrphanedPresentation = getAlertThresholdsBackupOrphanedPresentation(); const dockerServicePresentation = getAlertThresholdsDockerServicePresentation(); + const dockerUpdatePresentation = getAlertThresholdsDockerUpdatePresentation(); const dockerIgnoredPrefixesPresentation = getAlertThresholdsDockerIgnoredPrefixesPresentation(); const { isCollapsed, toggleSection, setCollapsed, expandAll, collapseAll } = @@ -120,6 +123,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) { const serviceWarnInputId = 'docker-service-warn-gap'; const serviceCriticalInputId = 'docker-service-critical-gap'; + const dockerUpdateDelayInputId = 'docker-update-alert-delay'; createEffect(() => { const remote = props.dockerIgnoredPrefixes(); @@ -143,7 +147,14 @@ export function useThresholdsTableState(props: ThresholdsTableProps) { 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 path = location.pathname; @@ -848,6 +859,8 @@ export function useThresholdsTableState(props: ThresholdsTableProps) { dockerIgnoredInput, dockerIgnoredPrefixesPresentation, dockerServicePresentation, + dockerUpdatePresentation, + dockerUpdateDelayInputId, editingId, editingNote, editingThresholds, diff --git a/frontend-modern/src/features/alerts/thresholds/types.ts b/frontend-modern/src/features/alerts/thresholds/types.ts index b3da06d3dc..ce0fe3ec57 100644 --- a/frontend-modern/src/features/alerts/thresholds/types.ts +++ b/frontend-modern/src/features/alerts/thresholds/types.ts @@ -156,6 +156,7 @@ export interface ThresholdsTableProps { memoryCriticalPct: number; serviceWarnGapPercent: number; serviceCriticalGapPercent: number; + updateAlertDelayHours: number; }; dockerDisableConnectivity: () => boolean; setDockerDisableConnectivity: (value: boolean) => void; @@ -173,6 +174,7 @@ export interface ThresholdsTableProps { memoryCriticalPct: number; serviceWarnGapPercent: number; serviceCriticalGapPercent: number; + updateAlertDelayHours: number; } | ((prev: { cpu: number; @@ -184,6 +186,7 @@ export interface ThresholdsTableProps { memoryCriticalPct: number; serviceWarnGapPercent: number; serviceCriticalGapPercent: number; + updateAlertDelayHours: number; }) => { cpu: number; memory: number; @@ -194,6 +197,7 @@ export interface ThresholdsTableProps { memoryCriticalPct: number; serviceWarnGapPercent: number; serviceCriticalGapPercent: number; + updateAlertDelayHours: number; }), ) => void; dockerIgnoredPrefixes: () => string[]; diff --git a/frontend-modern/src/types/alerts.ts b/frontend-modern/src/types/alerts.ts index a0460eec9e..250a457fd3 100644 --- a/frontend-modern/src/types/alerts.ts +++ b/frontend-modern/src/types/alerts.ts @@ -71,6 +71,7 @@ export interface DockerThresholdConfig { memoryCriticalPct?: number; serviceWarnGapPercent?: number; serviceCriticalGapPercent?: number; + updateAlertDelayHours?: number; stateDisableConnectivity?: boolean; statePoweredOffSeverity?: 'warning' | 'critical'; } diff --git a/frontend-modern/src/utils/__tests__/alertThresholdDefaults.test.ts b/frontend-modern/src/utils/__tests__/alertThresholdDefaults.test.ts index 1aca31000b..4fe8acf19b 100644 --- a/frontend-modern/src/utils/__tests__/alertThresholdDefaults.test.ts +++ b/frontend-modern/src/utils/__tests__/alertThresholdDefaults.test.ts @@ -162,6 +162,7 @@ describe('alertThresholdDefaults', () => { memoryCriticalPct: 95, serviceWarnGapPercent: 10, serviceCriticalGapPercent: 50, + updateAlertDelayHours: 24, }); }); diff --git a/frontend-modern/src/utils/alertThresholdDefaults.ts b/frontend-modern/src/utils/alertThresholdDefaults.ts index 9558b26be7..4354885d5a 100644 --- a/frontend-modern/src/utils/alertThresholdDefaults.ts +++ b/frontend-modern/src/utils/alertThresholdDefaults.ts @@ -83,6 +83,8 @@ export const FACTORY_DOCKER_DEFAULTS = { memoryCriticalPct: 95, serviceWarnGapPercent: 10, 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; diff --git a/frontend-modern/src/utils/alertThresholdsPresentation.ts b/frontend-modern/src/utils/alertThresholdsPresentation.ts index 5ee181485c..432d33cb2d 100644 --- a/frontend-modern/src/utils/alertThresholdsPresentation.ts +++ b/frontend-modern/src/utils/alertThresholdsPresentation.ts @@ -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.'; export const ALERT_THRESHOLDS_DOCKER_SERVICES_GAP_VALIDATION_MESSAGE = '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_PBS = 'PBS Servers'; export const ALERT_THRESHOLDS_SECTION_TITLE_GUESTS = 'VMs & Containers'; @@ -149,6 +158,17 @@ export function getAlertThresholdsDockerServicePresentation() { } 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() { return { nodes: ALERT_THRESHOLDS_SECTION_TITLE_NODES,