mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 16:42:15 +00:00
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:
@@ -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) {
|
||||
<>
|
||||
<Show when={props.state.hasDockerSpecificControls()}>
|
||||
<ThresholdsTableDockerIgnoredPrefixesSection {...props} />
|
||||
<ThresholdsTableDockerUpdateAlertsSection {...props} />
|
||||
<ThresholdsTableDockerServiceGapSection {...props} />
|
||||
</Show>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+73
@@ -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));
|
||||
};
|
||||
|
||||
// -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface DockerThresholdConfig {
|
||||
memoryCriticalPct?: number;
|
||||
serviceWarnGapPercent?: number;
|
||||
serviceCriticalGapPercent?: number;
|
||||
updateAlertDelayHours?: number;
|
||||
stateDisableConnectivity?: boolean;
|
||||
statePoweredOffSeverity?: 'warning' | 'critical';
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ describe('alertThresholdDefaults', () => {
|
||||
memoryCriticalPct: 95,
|
||||
serviceWarnGapPercent: 10,
|
||||
serviceCriticalGapPercent: 50,
|
||||
updateAlertDelayHours: 24,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user