diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index ed2572a37..43da91a43 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -43,6 +43,11 @@ command errors retain bounded stderr for compatibility diagnosis.
websocket payloads. Carrying plural availability facets through that serializer
is an adjacent monitoring/API projection and does not change agent enrollment,
report admission, removal, update, profile, or command authority.
+That shared monitor constructor may also copy the persisted alert schedule's
+initial notification target into the notification manager. This adjacent
+alerts/notifications wiring grants no agent enrollment, reporting, removal,
+profile, update, probe-assignment, or command authority and must not mutate
+host-agent state.
The JSON-excluded Proxmox VM/LXC I/O-rate validity fields carried by
`internal/models/models.go` are likewise monitoring-owned sidecar evidence.
They distinguish a valid idle interval from an unknown rate for history,
diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index 89be41f62..5c00400ee 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -17,6 +17,13 @@
Own alert identity, alert specs, evaluation, persistence semantics, and
operator-facing alert routing behavior for live runtime alerts.
+Alert schedule delivery routing is one persisted, normalized contract.
+`schedule.initialNotify` accepts `all`, `email`, `webhook`, or `apprise`;
+missing, legacy, and unknown values preserve the backward-compatible `all`
+target. The selected initial target owns firing, grouped, and recovery delivery,
+while every escalation level retains its own independently normalized target.
+Saving the alert configuration must update the live notification manager as
+well as persistence so delivery does not differ before and after restart.
Docker and Podman container CPU thresholds evaluate host-capacity-normalized
CPU percent, not Docker's runtime-native per-core percent. Alert metadata may
carry the raw per-core value and reporting host CPU count for evidence, but the
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index ce76519dd..a57384648 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -23,6 +23,12 @@
Own canonical runtime payload shapes between backend and frontend, including
the trust boundary that keeps customer-safe support diagnostics and normal
product API routes free of maintainer commercial analytics.
+`GET /api/alerts/config` and `PUT /api/alerts/config` carry
+`schedule.initialNotify` as the canonical initial notification-routing field.
+The accepted values are `all`, `email`, `webhook`, and `apprise`; the backend
+normalizes aliases and invalid values before responding, persisting, and
+updating the live notification runtime. Escalation levels use the same value
+vocabulary but remain independent of the initial target.
Physical-disk payloads preserve optional SMART counter presence, including
explicit zero values, and expose provider vendor metadata without converting
missing data into health. Unified-resource clients may request bounded server
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index a72b8c686..3d4f4b3e6 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -22,6 +22,11 @@
Own reusable frontend primitives and canonical page-shell patterns so feature
work extends shared components instead of creating new local variants.
+The alert schedule's initial-delivery selector composes `SettingsPanel` and
+`FormSelect`, uses the shared alert-configuration presentation vocabulary, and
+exposes the same email, webhook, Apprise, and all-destination labels used by
+escalation. It must not introduce a page-local select shell or a second
+destination-label map.
Platform-owned workload controls extend the shared `WorkloadsFilter` view
options rather than creating page-local toolbar shells. The Proxmox overview
diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md
index b3d91bc79..4294ecdc9 100644
--- a/docs/release-control/v6/internal/subsystems/monitoring.md
+++ b/docs/release-control/v6/internal/subsystems/monitoring.md
@@ -19,6 +19,11 @@
Own polling, typed collection, runtime state assembly, and canonical monitoring
truth for live infrastructure data.
+Monitor construction also applies the persisted alert schedule's normalized
+initial-delivery target to the tenant notification manager. This is runtime
+wiring only: monitoring does not choose destinations or own notification
+policy, and live API saves must apply the same setting without requiring a
+monitor restart.
Monitoring also owns the distinction between Proxmox VM power state and QEMU
guest-agent reachability: fresh or never-healthy VMs with an enabled but
unavailable guest agent stay `not-running`, while only VMs with recent healthy
diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md
index f64b7d7c2..56cb185b7 100644
--- a/docs/release-control/v6/internal/subsystems/notifications.md
+++ b/docs/release-control/v6/internal/subsystems/notifications.md
@@ -17,6 +17,12 @@
Own notification delivery transport, provider configuration, queueing, and
notification-management API surfaces.
+The alert schedule selects firing, grouped, and matching recovery delivery
+through one normalized target (`all`, `email`, `webhook`, or `apprise`).
+Escalation delivery remains independently targetable per level, allowing an
+Apprise/ntfy first notification to escalate through email, or the reverse.
+Unknown and absent persisted targets preserve historical all-destination
+behavior, and destination tag filters still apply after target selection.
## Canonical Files
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 13c7c0b13..8fee147e9 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -35,6 +35,10 @@ availability-check identity by replaying authoritative supplemental records.
That availability composition remains owned by API contracts and unified
resources; it does not make availability rows recovery points, storage health,
backup evidence, or restore authority.
+The shared alerts API may persist and apply `schedule.initialNotify` for email,
+webhook, or Apprise delivery. That notification routing is not storage-health,
+backup, recovery-point, restore, or protection evidence; storage/recovery
+surfaces must not infer product state from the selected destination.
The shared Proxmox config discovery path likewise persists powered-off cluster
members and separates membership from member API reachability. Storage and
recovery consumers may use the resulting provider-scoped node/instance
diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json
index 8f48ccd4b..85ea9eb6c 100644
--- a/frontend-modern/scripts/shared-template-registry.json
+++ b/frontend-modern/scripts/shared-template-registry.json
@@ -1599,6 +1599,7 @@
{ "path": "src/components/shared/FilterBar/AddFilterMenu.tsx" },
{ "path": "src/components/shared/FilterToolbar.tsx" },
{ "path": "src/features/alerts/AlertAppriseDestinationsSection.tsx" },
+ { "path": "src/features/alerts/AlertDeliveryRoutingSection.tsx" },
{ "path": "src/features/alerts/AlertEscalationSection.tsx" },
{ "path": "src/features/alerts/AlertQuietHoursSection.tsx" }
],
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index cbcb9fb89..8ba062ceb 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -653,6 +653,7 @@ describe('shared primitive guardrails', () => {
'src/components/shared/FilterBar/AddFilterMenu.tsx',
'src/components/shared/FilterToolbar.tsx',
'src/features/alerts/AlertAppriseDestinationsSection.tsx',
+ 'src/features/alerts/AlertDeliveryRoutingSection.tsx',
'src/features/alerts/AlertEscalationSection.tsx',
'src/features/alerts/AlertQuietHoursSection.tsx',
]);
diff --git a/frontend-modern/src/features/alerts/AlertDeliveryRoutingSection.tsx b/frontend-modern/src/features/alerts/AlertDeliveryRoutingSection.tsx
new file mode 100644
index 000000000..8cb90f09b
--- /dev/null
+++ b/frontend-modern/src/features/alerts/AlertDeliveryRoutingSection.tsx
@@ -0,0 +1,47 @@
+import { createUniqueId } from 'solid-js';
+
+import { formHelpText } from '@/components/shared/Form';
+import { FormSelect } from '@/components/shared/FormSelect';
+import { SettingsPanel } from '@/components/shared/SettingsPanel';
+import {
+ ALERT_CONFIG_DELIVERY_DESCRIPTION,
+ ALERT_CONFIG_DELIVERY_HELP,
+ ALERT_CONFIG_DELIVERY_TARGET_LABEL,
+ ALERT_CONFIG_DELIVERY_TITLE,
+ getAlertConfigEscalationNotifyLabel,
+} from '@/utils/alertConfigPresentation';
+
+import type { NotificationDeliveryTarget } from './types';
+
+interface AlertDeliveryRoutingSectionProps {
+ initialNotify: NotificationDeliveryTarget;
+ setInitialNotifyTarget: (value: NotificationDeliveryTarget) => void;
+}
+
+export function AlertDeliveryRoutingSection(props: AlertDeliveryRoutingSectionProps) {
+ const fieldId = `alert-initial-delivery-${createUniqueId()}`;
+
+ return (
+
+
+
+ props.setInitialNotifyTarget(event.currentTarget.value as NotificationDeliveryTarget)
+ }
+ >
+ {getAlertConfigEscalationNotifyLabel('all')}
+ {getAlertConfigEscalationNotifyLabel('email')}
+ {getAlertConfigEscalationNotifyLabel('webhook')}
+ {getAlertConfigEscalationNotifyLabel('apprise')}
+
+
{ALERT_CONFIG_DELIVERY_HELP}
+
+
+ );
+}
diff --git a/frontend-modern/src/features/alerts/AlertEscalationSection.tsx b/frontend-modern/src/features/alerts/AlertEscalationSection.tsx
index 382fcd43a..0eae2c3d7 100644
--- a/frontend-modern/src/features/alerts/AlertEscalationSection.tsx
+++ b/frontend-modern/src/features/alerts/AlertEscalationSection.tsx
@@ -102,6 +102,9 @@ export function AlertEscalationSection(props: AlertEscalationSectionProps) {
{getAlertConfigEscalationNotifyLabel('webhook')}
+
+ {getAlertConfigEscalationNotifyLabel('apprise')}
+
{getAlertConfigEscalationNotifyLabel('all')}
diff --git a/frontend-modern/src/features/alerts/AlertScheduleSections.test.tsx b/frontend-modern/src/features/alerts/AlertScheduleSections.test.tsx
index 87ef726ec..ef0a00275 100644
--- a/frontend-modern/src/features/alerts/AlertScheduleSections.test.tsx
+++ b/frontend-modern/src/features/alerts/AlertScheduleSections.test.tsx
@@ -1,6 +1,7 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertCooldownSection } from './AlertCooldownSection';
+import { AlertDeliveryRoutingSection } from './AlertDeliveryRoutingSection';
import { AlertEscalationSection } from './AlertEscalationSection';
import { AlertGroupingSection } from './AlertGroupingSection';
import { AlertQuietHoursSection } from './AlertQuietHoursSection';
@@ -138,9 +139,24 @@ describe('Alert schedule sections', () => {
);
expect(screen.getByRole('spinbutton', { name: 'After' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Notify' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Apprise' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove escalation level' })).toBeInTheDocument();
});
+ it('associates the initial delivery selector and exposes every destination type', () => {
+ render(() => (
+
+ ));
+
+ expect(screen.getByRole('combobox', { name: 'Send initial alerts to' })).toHaveValue('webhook');
+ expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
+ 'All channels',
+ 'Email',
+ 'Webhooks',
+ 'Apprise',
+ ]);
+ });
+
it('associates the visible recovery heading with its status toggle', () => {
render(() => (
diff --git a/frontend-modern/src/features/alerts/AlertScheduleSummarySection.tsx b/frontend-modern/src/features/alerts/AlertScheduleSummarySection.tsx
index b775d2c4c..017d66f99 100644
--- a/frontend-modern/src/features/alerts/AlertScheduleSummarySection.tsx
+++ b/frontend-modern/src/features/alerts/AlertScheduleSummarySection.tsx
@@ -6,6 +6,7 @@ import {
ALERT_CONFIG_SUMMARY_TITLE,
getAlertConfigSummaryAllDisabled,
getAlertConfigSummaryCooldown,
+ getAlertConfigSummaryDelivery,
getAlertConfigSummaryEscalation,
getAlertConfigSummaryGrouping,
getAlertConfigSummaryQuietHours,
@@ -13,7 +14,13 @@ import {
getAlertConfigSummarySuppressing,
} from '@/utils/alertConfigPresentation';
-import type { CooldownConfig, EscalationConfig, GroupingConfig, QuietHoursConfig } from './types';
+import type {
+ CooldownConfig,
+ EscalationConfig,
+ GroupingConfig,
+ NotificationDeliveryTarget,
+ QuietHoursConfig,
+} from './types';
interface QuietSuppressOption {
key: keyof QuietHoursConfig['suppress'];
@@ -25,6 +32,7 @@ interface AlertScheduleSummarySectionProps {
quietHours: QuietHoursConfig;
cooldown: CooldownConfig;
grouping: GroupingConfig;
+ initialNotify: NotificationDeliveryTarget;
notifyOnResolve: boolean;
escalation: EscalationConfig;
quietHourSuppressOptions: QuietSuppressOption[];
@@ -77,6 +85,7 @@ export function AlertScheduleSummarySection(props: AlertScheduleSummarySectionPr
)}
+ {getAlertConfigSummaryDelivery(props.initialNotify)}
{getAlertConfigSummaryRecoveryEnabled()}
diff --git a/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx b/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx
index 7ad2096ae..f2f3c2618 100644
--- a/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx
+++ b/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx
@@ -232,6 +232,8 @@ export function AlertsConfigurationSurface(props: AlertsConfigurationSurfaceProp
setCooldown={state.setScheduleCooldown}
grouping={state.scheduleGrouping}
setGrouping={state.setScheduleGrouping}
+ initialNotify={state.initialNotify}
+ setInitialNotify={state.setInitialNotify}
notifyOnResolve={state.notifyOnResolve}
setNotifyOnResolve={state.setNotifyOnResolve}
escalation={state.scheduleEscalation}
diff --git a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
index 5251ad61d..2dfbde75f 100644
--- a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
+++ b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
@@ -25,6 +25,7 @@ describe('alertsConfigurationModel', () => {
expect(snapshot.timeThresholds['vmware-host']).toBe(5);
expect(snapshot.vmwareDefaults.usage).toBe(85);
expect(snapshot.scheduleCooldown.enabled).toBe(true);
+ expect(snapshot.initialNotify).toBe('all');
expect(snapshot.backupDefaults.ignoreVMIDs).toEqual([]);
});
@@ -69,6 +70,11 @@ describe('alertsConfigurationModel', () => {
},
cooldown: 45,
maxAlertsHour: 7,
+ initialNotify: 'apprise',
+ escalation: {
+ enabled: true,
+ levels: [{ after: 30, notify: 'APPRISE' }],
+ },
},
} as AlertConfig;
@@ -96,6 +102,8 @@ describe('alertsConfigurationModel', () => {
saturday: false,
});
expect(snapshot.scheduleCooldown.maxAlerts).toBe(7);
+ expect(snapshot.initialNotify).toBe('apprise');
+ expect(snapshot.scheduleEscalation.levels).toEqual([{ after: 30, notify: 'apprise' }]);
});
it('builds the canonical save payload from the runtime snapshot', () => {
@@ -117,6 +125,7 @@ describe('alertsConfigurationModel', () => {
byNode: true,
byGuest: false,
};
+ snapshot.initialNotify = 'email';
snapshot.backupDefaults = {
enabled: true,
warningDays: 30,
@@ -147,6 +156,7 @@ describe('alertsConfigurationModel', () => {
expect(result.alertConfig?.guestTagWhitelist).toEqual(['prod']);
expect(result.alertConfig?.metricTimeThresholds).toEqual({ guest: { cpu: 17 } });
expect(result.alertConfig?.schedule?.cooldown).toBe(47);
+ expect(result.alertConfig?.schedule?.initialNotify).toBe('email');
expect(result.alertConfig?.schedule?.maxAlertsHour).toBe(10);
expect(result.alertConfig?.schedule?.grouping).toEqual({
enabled: true,
diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx
index 2e4091990..ad07f9454 100644
--- a/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx
+++ b/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx
@@ -17,6 +17,9 @@ describe('useAlertScheduleState', () => {
const [quietHours, setQuietHours] = createSignal(createDefaultQuietHours());
const [cooldown, setCooldown] = createSignal(createDefaultCooldown());
const [grouping, setGrouping] = createSignal(createDefaultGrouping());
+ const [initialNotify, setInitialNotify] = createSignal<'email' | 'webhook' | 'apprise' | 'all'>(
+ 'all',
+ );
const [notifyOnResolve, setNotifyOnResolve] = createSignal(false);
const [escalation, setEscalation] = createSignal(createDefaultEscalation());
@@ -29,6 +32,8 @@ describe('useAlertScheduleState', () => {
setCooldown,
grouping,
setGrouping,
+ initialNotify,
+ setInitialNotify,
notifyOnResolve,
setNotifyOnResolve,
escalation,
@@ -46,6 +51,7 @@ describe('useAlertScheduleState', () => {
result.setGroupingEnabled(true);
result.setGroupingWindow('8');
result.setGroupingByNode(true);
+ result.setInitialNotifyTarget('apprise');
result.setNotifyOnResolveEnabled(true);
result.setEscalationEnabled(true);
result.addEscalationLevel();
@@ -62,6 +68,7 @@ describe('useAlertScheduleState', () => {
expect(result.weekendsOnly()).toBe(false);
expect(cooldown()).toMatchObject({ enabled: true, minutes: 45, maxAlerts: 5 });
expect(grouping()).toMatchObject({ enabled: true, window: 8, byNode: true });
+ expect(initialNotify()).toBe('apprise');
expect(notifyOnResolve()).toBe(true);
expect(escalation()).toMatchObject({
enabled: true,
@@ -76,6 +83,7 @@ describe('useAlertScheduleState', () => {
expect(quietHours()).toEqual(createDefaultQuietHours());
expect(cooldown()).toEqual(createDefaultCooldown());
expect(grouping()).toEqual(createDefaultGrouping());
+ expect(initialNotify()).toBe('all');
expect(notifyOnResolve()).toBe(createDefaultResolveNotifications());
expect(escalation()).toEqual(createDefaultEscalation());
});
diff --git a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
index 1e415fa99..6363feda9 100644
--- a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
+++ b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
@@ -40,6 +40,7 @@ import type {
EscalationConfig,
EscalationNotifyTarget,
GroupingConfig,
+ NotificationDeliveryTarget,
QuietHoursConfig,
} from './types';
import { GROUPING_WINDOW_DEFAULT_SECONDS, clampCooldownMinutes } from './types';
@@ -67,6 +68,7 @@ export interface AlertsConfigurationSnapshot {
scheduleCooldown: CooldownConfig;
scheduleGrouping: GroupingConfig;
scheduleEscalation: EscalationConfig;
+ initialNotify: NotificationDeliveryTarget;
notifyOnResolve: boolean;
guestDefaults: Record;
guestDisableConnectivity: boolean;
@@ -218,12 +220,29 @@ const normalizeWarningCriticalPair = (
const normalizeStringList = (values: string[] | undefined): string[] =>
(values ?? []).map((value) => value.trim()).filter((value) => value.length > 0);
+const normalizeNotificationDeliveryTarget = (
+ value: string | undefined,
+): NotificationDeliveryTarget => {
+ switch (value?.trim().toLowerCase()) {
+ case 'email':
+ return 'email';
+ case 'webhook':
+ case 'webhooks':
+ return 'webhook';
+ case 'apprise':
+ return 'apprise';
+ default:
+ return 'all';
+ }
+};
+
export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationSnapshot {
return {
scheduleQuietHours: createDefaultQuietHours(),
scheduleCooldown: createDefaultCooldown(),
scheduleGrouping: createDefaultGrouping(),
scheduleEscalation: createDefaultEscalation(),
+ initialNotify: 'all',
notifyOnResolve: createDefaultResolveNotifications(),
guestDefaults: { ...FACTORY_GUEST_DEFAULTS },
guestDisableConnectivity: false,
@@ -578,6 +597,8 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf
snapshot.disableAllDockerHostsOffline = config.disableAllDockerHostsOffline ?? false;
if (config.schedule) {
+ snapshot.initialNotify = normalizeNotificationDeliveryTarget(config.schedule.initialNotify);
+
if (config.schedule.quietHours) {
const quietHours = config.schedule.quietHours;
const days = Array.isArray(quietHours.days)
@@ -642,7 +663,7 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf
enabled: Boolean(config.schedule.escalation.enabled),
levels: (config.schedule.escalation.levels || []).map((level) => ({
after: typeof level.after === 'number' ? level.after : 15,
- notify: (level.notify as EscalationNotifyTarget) || 'all',
+ notify: normalizeNotificationDeliveryTarget(level.notify) as EscalationNotifyTarget,
})),
};
}
@@ -827,6 +848,7 @@ export function buildAlertsConfigurationPayload({
days: cloneDays(snapshot.scheduleQuietHours.days),
},
cooldown: normalizedCooldownMinutes,
+ initialNotify: snapshot.initialNotify,
notifyOnResolve: snapshot.notifyOnResolve,
maxAlertsHour: normalizedMaxAlertsHour,
escalation: {
diff --git a/frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx b/frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx
index 1b2b135a9..db0df5371 100644
--- a/frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx
+++ b/frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx
@@ -8,11 +8,18 @@ import {
import { AlertCooldownSection } from '../AlertCooldownSection';
import { AlertEscalationSection } from '../AlertEscalationSection';
import { AlertGroupingSection } from '../AlertGroupingSection';
+import { AlertDeliveryRoutingSection } from '../AlertDeliveryRoutingSection';
import { AlertQuietHoursSection } from '../AlertQuietHoursSection';
import { AlertRecoverySection } from '../AlertRecoverySection';
import { AlertScheduleSummarySection } from '../AlertScheduleSummarySection';
import { useAlertScheduleState } from '../useAlertScheduleState';
-import type { CooldownConfig, EscalationConfig, GroupingConfig, QuietHoursConfig } from '../types';
+import type {
+ CooldownConfig,
+ EscalationConfig,
+ GroupingConfig,
+ NotificationDeliveryTarget,
+ QuietHoursConfig,
+} from '../types';
export interface ScheduleTabProps {
setHasUnsavedChanges: (value: boolean) => void;
@@ -22,6 +29,8 @@ export interface ScheduleTabProps {
setCooldown: (value: CooldownConfig) => void;
grouping: () => GroupingConfig;
setGrouping: (value: GroupingConfig) => void;
+ initialNotify: () => NotificationDeliveryTarget;
+ setInitialNotify: (value: NotificationDeliveryTarget) => void;
notifyOnResolve: () => boolean;
setNotifyOnResolve: (value: boolean) => void;
escalation: () => EscalationConfig;
@@ -95,6 +104,11 @@ export function ScheduleTab(props: ScheduleTabProps) {
setGroupingByGuest={scheduleState.setGroupingByGuest}
/>
+
+
void;
grouping: () => GroupingConfig;
setGrouping: (value: GroupingConfig) => void;
+ initialNotify: () => NotificationDeliveryTarget;
+ setInitialNotify: (value: NotificationDeliveryTarget) => void;
notifyOnResolve: () => boolean;
setNotifyOnResolve: (value: boolean) => void;
escalation: () => EscalationConfig;
@@ -126,6 +129,7 @@ export function useAlertScheduleState(props: UseAlertScheduleStateProps) {
props.setQuietHours(createDefaultQuietHours());
props.setCooldown(createDefaultCooldown());
props.setGrouping(createDefaultGrouping());
+ props.setInitialNotify('all');
props.setNotifyOnResolve(createDefaultResolveNotifications());
props.setEscalation(createDefaultEscalation());
markUnsaved();
@@ -283,6 +287,11 @@ export function useAlertScheduleState(props: UseAlertScheduleStateProps) {
markUnsaved();
};
+ const setInitialNotifyTarget = (target: NotificationDeliveryTarget) => {
+ props.setInitialNotify(target);
+ markUnsaved();
+ };
+
const setEscalationEnabled = (enabled: boolean) => {
props.setEscalation({
...props.escalation(),
@@ -360,6 +369,7 @@ export function useAlertScheduleState(props: UseAlertScheduleStateProps) {
setGroupingWindow,
setGroupingByNode,
setGroupingByGuest,
+ setInitialNotifyTarget,
setNotifyOnResolveEnabled,
setEscalationEnabled,
setEscalationAfter,
diff --git a/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts b/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
index 40a20650e..44c4e6bb5 100644
--- a/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
+++ b/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
@@ -21,7 +21,13 @@ import {
FACTORY_VMWARE_DEFAULTS,
type AlertsConfigurationSnapshot,
} from './alertsConfigurationModel';
-import type { CooldownConfig, EscalationConfig, GroupingConfig, QuietHoursConfig } from './types';
+import type {
+ CooldownConfig,
+ EscalationConfig,
+ GroupingConfig,
+ NotificationDeliveryTarget,
+ QuietHoursConfig,
+} from './types';
interface UseAlertsConfigurationSnapshotStateProps {
setHasUnsavedChanges: (value: boolean) => void;
@@ -43,6 +49,9 @@ export function useAlertsConfigurationSnapshotState(
const [scheduleEscalation, setScheduleEscalation] = createSignal(
defaultSnapshot.scheduleEscalation,
);
+ const [initialNotify, setInitialNotify] = createSignal(
+ defaultSnapshot.initialNotify,
+ );
const [notifyOnResolve, setNotifyOnResolve] = createSignal(
defaultSnapshot.notifyOnResolve,
);
@@ -165,6 +174,7 @@ export function useAlertsConfigurationSnapshotState(
enabled: snapshot.scheduleEscalation.enabled,
levels: snapshot.scheduleEscalation.levels.map((level) => ({ ...level })),
});
+ setInitialNotify(snapshot.initialNotify);
setNotifyOnResolve(snapshot.notifyOnResolve);
setGuestDefaults({ ...snapshot.guestDefaults });
setGuestDisableConnectivity(snapshot.guestDisableConnectivity);
@@ -226,6 +236,7 @@ export function useAlertsConfigurationSnapshotState(
enabled: scheduleEscalation().enabled,
levels: scheduleEscalation().levels.map((level) => ({ ...level })),
},
+ initialNotify: initialNotify(),
notifyOnResolve: notifyOnResolve(),
guestDefaults: { ...guestDefaults() },
guestDisableConnectivity: guestDisableConnectivity(),
@@ -344,6 +355,8 @@ export function useAlertsConfigurationSnapshotState(
setScheduleGrouping,
scheduleEscalation,
setScheduleEscalation,
+ initialNotify,
+ setInitialNotify,
notifyOnResolve,
setNotifyOnResolve,
guestDefaults,
diff --git a/frontend-modern/src/types/alerts.ts b/frontend-modern/src/types/alerts.ts
index 250a457fd..519da5dd3 100644
--- a/frontend-modern/src/types/alerts.ts
+++ b/frontend-modern/src/types/alerts.ts
@@ -217,6 +217,7 @@ export interface AlertConfig {
};
cooldown?: number;
maxAlertsHour?: number;
+ initialNotify?: string;
notifyOnResolve?: boolean;
grouping?: {
enabled: boolean;
diff --git a/frontend-modern/src/utils/__tests__/alertConfigPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertConfigPresentation.test.ts
index 5628f16e3..975810bcc 100644
--- a/frontend-modern/src/utils/__tests__/alertConfigPresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/alertConfigPresentation.test.ts
@@ -8,8 +8,13 @@ import {
ALERT_CONFIG_COOLDOWN_PERIOD_LABEL,
ALERT_CONFIG_COOLDOWN_PERIOD_SUFFIX,
ALERT_CONFIG_COOLDOWN_TITLE,
+ ALERT_CONFIG_DELIVERY_DESCRIPTION,
+ ALERT_CONFIG_DELIVERY_HELP,
+ ALERT_CONFIG_DELIVERY_TARGET_LABEL,
+ ALERT_CONFIG_DELIVERY_TITLE,
ALERT_CONFIG_ESCALATION_DESCRIPTION,
ALERT_CONFIG_ESCALATION_NOTIFY_ALL,
+ ALERT_CONFIG_ESCALATION_NOTIFY_APPRISE,
ALERT_CONFIG_ESCALATION_NOTIFY_EMAIL,
ALERT_CONFIG_ESCALATION_NOTIFY_WEBHOOKS,
ALERT_CONFIG_ESCALATION_TITLE,
@@ -60,6 +65,7 @@ import {
getAlertConfigSaveChangesLabel,
getAlertConfigSaveSuccess,
getAlertConfigSummaryCooldown,
+ getAlertConfigSummaryDelivery,
getAlertConfigSummaryEscalation,
getAlertConfigSummaryGrouping,
getAlertConfigSummaryAllDisabled,
@@ -76,7 +82,7 @@ describe('alertConfigPresentation', () => {
expect(ALERT_CONFIG_SAVE_CHANGES).toBe('Save Changes');
expect(ALERT_CONFIG_RESET_DEFAULTS).toBe('Reset to defaults');
expect(ALERT_CONFIG_RESET_DEFAULTS_TITLE).toBe(
- 'Restore quiet hours, cooldown, grouping, and escalation settings to their defaults',
+ 'Restore quiet hours, cooldown, grouping, delivery, and escalation settings to their defaults',
);
expect(ALERT_CONFIG_SCHEDULING_TITLE).toBe('Alert scheduling');
expect(ALERT_CONFIG_SCHEDULING_DESCRIPTION).toBe('Configure when and how alerts are delivered');
@@ -131,11 +137,20 @@ describe('alertConfigPresentation', () => {
expect(ALERT_CONFIG_ESCALATION_DESCRIPTION).toBe(
'Notify additional contacts for persistent issues.',
);
+ expect(ALERT_CONFIG_DELIVERY_TITLE).toBe('Initial delivery');
+ expect(ALERT_CONFIG_DELIVERY_DESCRIPTION).toBe(
+ 'Choose where firing and recovery notifications are sent.',
+ );
+ expect(ALERT_CONFIG_DELIVERY_TARGET_LABEL).toBe('Send initial alerts to');
+ expect(ALERT_CONFIG_DELIVERY_HELP).toBe(
+ 'Escalation levels can use a different destination for persistent alerts.',
+ );
expect(ALERT_CONFIG_RECOVERY_HELP).toBe(
'Sends on the same channels as live alerts to confirm when a condition clears.',
);
expect(ALERT_CONFIG_ESCALATION_NOTIFY_EMAIL).toBe('Email');
expect(ALERT_CONFIG_ESCALATION_NOTIFY_WEBHOOKS).toBe('Webhooks');
+ expect(ALERT_CONFIG_ESCALATION_NOTIFY_APPRISE).toBe('Apprise');
expect(ALERT_CONFIG_ESCALATION_NOTIFY_ALL).toBe('All channels');
expect(ALERT_CONFIG_SUMMARY_TITLE).toBe('Configuration summary');
expect(ALERT_CONFIG_SUMMARY_DESCRIPTION).toBe('Preview of the active schedule settings.');
@@ -154,7 +169,7 @@ describe('alertConfigPresentation', () => {
expect(getAlertConfigSaveChangesLabel()).toBe('Save Changes');
expect(getAlertConfigResetDefaultsLabel()).toBe('Reset to defaults');
expect(getAlertConfigResetDefaultsTitle()).toBe(
- 'Restore quiet hours, cooldown, grouping, and escalation settings to their defaults',
+ 'Restore quiet hours, cooldown, grouping, delivery, and escalation settings to their defaults',
);
expect(getAlertConfigDiscardedSuccess()).toBe('Changes discarded');
expect(getAlertConfigReloadFailure()).toBe('Failed to reload configuration');
@@ -185,7 +200,11 @@ describe('alertConfigPresentation', () => {
expect(getAlertConfigEscalationHelp()).toBe('Define escalation levels for unresolved alerts:');
expect(getAlertConfigEscalationNotifyLabel('email')).toBe('Email');
expect(getAlertConfigEscalationNotifyLabel('webhook')).toBe('Webhooks');
+ expect(getAlertConfigEscalationNotifyLabel('apprise')).toBe('Apprise');
expect(getAlertConfigEscalationNotifyLabel('all')).toBe('All channels');
+ expect(getAlertConfigSummaryDelivery('apprise')).toBe(
+ '• Initial and recovery notifications use Apprise',
+ );
expect(getAlertConfigSummaryRecoveryEnabled()).toBe(ALERT_CONFIG_SUMMARY_RECOVERY);
expect(getAlertConfigSummaryEscalation(2)).toBe('• 2 escalation levels configured');
expect(getAlertConfigSummaryAllDisabled()).toBe(
diff --git a/frontend-modern/src/utils/alertConfigPresentation.ts b/frontend-modern/src/utils/alertConfigPresentation.ts
index e9e859f35..1f596b1b9 100644
--- a/frontend-modern/src/utils/alertConfigPresentation.ts
+++ b/frontend-modern/src/utils/alertConfigPresentation.ts
@@ -4,7 +4,7 @@ export const ALERT_CONFIG_UNSAVED_CHANGES = 'You have unsaved changes';
export const ALERT_CONFIG_SAVE_CHANGES = 'Save Changes';
export const ALERT_CONFIG_RESET_DEFAULTS = 'Reset to defaults';
export const ALERT_CONFIG_RESET_DEFAULTS_TITLE =
- 'Restore quiet hours, cooldown, grouping, and escalation settings to their defaults';
+ 'Restore quiet hours, cooldown, grouping, delivery, and escalation settings to their defaults';
export const ALERT_CONFIG_LEAVE_CONFIRMATION =
'You have unsaved changes that will be lost. Discard changes and leave?';
@@ -55,6 +55,12 @@ export const ALERT_CONFIG_RECOVERY_DESCRIPTION =
export const ALERT_CONFIG_ESCALATION_TITLE = 'Alert escalation';
export const ALERT_CONFIG_ESCALATION_DESCRIPTION =
'Notify additional contacts for persistent issues.';
+export const ALERT_CONFIG_DELIVERY_TITLE = 'Initial delivery';
+export const ALERT_CONFIG_DELIVERY_DESCRIPTION =
+ 'Choose where firing and recovery notifications are sent.';
+export const ALERT_CONFIG_DELIVERY_TARGET_LABEL = 'Send initial alerts to';
+export const ALERT_CONFIG_DELIVERY_HELP =
+ 'Escalation levels can use a different destination for persistent alerts.';
export const ALERT_CONFIG_SUMMARY_QUIET_HOURS_PREFIX = '• Quiet hours active from';
export const ALERT_CONFIG_SUMMARY_SUPPRESSING_PREFIX = '• Suppressing';
export const ALERT_CONFIG_SUMMARY_SUPPRESSING_SUFFIX = 'during quiet hours';
@@ -69,6 +75,7 @@ export const ALERT_CONFIG_ESCALATION_NOTIFY_LABEL = 'Notify';
export const ALERT_CONFIG_ESCALATION_MINUTES_SUFFIX = 'min';
export const ALERT_CONFIG_ESCALATION_NOTIFY_EMAIL = 'Email';
export const ALERT_CONFIG_ESCALATION_NOTIFY_WEBHOOKS = 'Webhooks';
+export const ALERT_CONFIG_ESCALATION_NOTIFY_APPRISE = 'Apprise';
export const ALERT_CONFIG_ESCALATION_NOTIFY_ALL = getAllFilterOptionLabel('channels');
export const ALERT_CONFIG_ESCALATION_REMOVE_TITLE = 'Remove escalation level';
export const ALERT_CONFIG_ESCALATION_ADD_LABEL = 'Add Escalation Level';
@@ -151,17 +158,23 @@ export function getAlertConfigEscalationHelp() {
return ALERT_CONFIG_ESCALATION_HELP;
}
-export function getAlertConfigEscalationNotifyLabel(type: 'email' | 'webhook' | 'all') {
+export function getAlertConfigEscalationNotifyLabel(type: 'email' | 'webhook' | 'apprise' | 'all') {
switch (type) {
case 'email':
return ALERT_CONFIG_ESCALATION_NOTIFY_EMAIL;
case 'webhook':
return ALERT_CONFIG_ESCALATION_NOTIFY_WEBHOOKS;
+ case 'apprise':
+ return ALERT_CONFIG_ESCALATION_NOTIFY_APPRISE;
default:
return ALERT_CONFIG_ESCALATION_NOTIFY_ALL;
}
}
+export function getAlertConfigSummaryDelivery(target: 'email' | 'webhook' | 'apprise' | 'all') {
+ return `• Initial and recovery notifications use ${getAlertConfigEscalationNotifyLabel(target)}`;
+}
+
export function getAlertConfigSummaryAllDisabled() {
return ALERT_CONFIG_SUMMARY_ALL_DISABLED;
}
diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go
index f82a26c35..b2f56357d 100644
--- a/internal/alerts/alerts_test.go
+++ b/internal/alerts/alerts_test.go
@@ -19316,6 +19316,30 @@ func TestDefaultAlertConfigUsesIndependentBackupAlertOrphanedPointer(t *testing.
}
}
+func TestUpdateConfigNormalizesNotificationDeliveryTargets(t *testing.T) {
+ manager := NewManager()
+ t.Cleanup(manager.Stop)
+
+ config := manager.GetConfig()
+ config.Schedule.InitialNotify = " APPRISE "
+ config.Schedule.Escalation.Levels = []EscalationLevel{
+ {After: 15, Notify: "WEBHOOKS"},
+ {After: 30, Notify: "unsupported"},
+ }
+ manager.UpdateConfig(config)
+
+ updated := manager.GetConfig()
+ if updated.Schedule.InitialNotify != "apprise" {
+ t.Fatalf("initial notify = %q, want apprise", updated.Schedule.InitialNotify)
+ }
+ if updated.Schedule.Escalation.Levels[0].Notify != "webhook" {
+ t.Fatalf("first escalation target = %q, want webhook", updated.Schedule.Escalation.Levels[0].Notify)
+ }
+ if updated.Schedule.Escalation.Levels[1].Notify != "all" {
+ t.Fatalf("invalid escalation target = %q, want all", updated.Schedule.Escalation.Levels[1].Notify)
+ }
+}
+
func TestDiagnoseAlertDeliveryReady(t *testing.T) {
manager, now := newDeliveryDiagnosisManager(t)
alert := addDeliveryDiagnosisAlert(manager, &Alert{
diff --git a/internal/alerts/config/notification_delivery_target_test.go b/internal/alerts/config/notification_delivery_target_test.go
new file mode 100644
index 000000000..247012f51
--- /dev/null
+++ b/internal/alerts/config/notification_delivery_target_test.go
@@ -0,0 +1,23 @@
+package config
+
+import "testing"
+
+func TestNormalizeNotificationDeliveryTarget(t *testing.T) {
+ tests := map[string]string{
+ "": "all",
+ "all": "all",
+ "EMAIL": "email",
+ " webhook ": "webhook",
+ "webhooks": "webhook",
+ "Apprise": "apprise",
+ "unknown": "all",
+ }
+
+ for input, expected := range tests {
+ t.Run(input, func(t *testing.T) {
+ if actual := NormalizeNotificationDeliveryTarget(input); actual != expected {
+ t.Fatalf("NormalizeNotificationDeliveryTarget(%q) = %q, want %q", input, actual, expected)
+ }
+ })
+ }
+}
diff --git a/internal/alerts/config/types.go b/internal/alerts/config/types.go
index 773b0108b..aaf0f8c57 100644
--- a/internal/alerts/config/types.go
+++ b/internal/alerts/config/types.go
@@ -78,7 +78,7 @@ type QuietHoursSuppression struct {
// EscalationLevel represents an escalation rule
type EscalationLevel struct {
After int `json:"after"` // minutes after initial alert
- Notify string `json:"notify"` // "email", "webhook", or "all"
+ Notify string `json:"notify"` // "email", "webhook", "apprise", or "all"
}
// EscalationConfig represents alert escalation configuration
@@ -100,11 +100,27 @@ type ScheduleConfig struct {
QuietHours QuietHours `json:"quietHours"`
Cooldown int `json:"cooldown"` // minutes
MaxAlertsHour int `json:"maxAlertsHour"` // max alerts per hour per resource
+ InitialNotify string `json:"initialNotify"` // "email", "webhook", "apprise", or "all"
NotifyOnResolve bool `json:"notifyOnResolve"` // Send notification when alert clears
Escalation EscalationConfig `json:"escalation"`
Grouping GroupingConfig `json:"grouping"`
}
+// NormalizeNotificationDeliveryTarget keeps schedule delivery routing backward
+// compatible while rejecting unknown values from persisted or API config.
+func NormalizeNotificationDeliveryTarget(target string) string {
+ switch strings.ToLower(strings.TrimSpace(target)) {
+ case "email":
+ return "email"
+ case "webhook", "webhooks":
+ return "webhook"
+ case "apprise":
+ return "apprise"
+ default:
+ return "all"
+ }
+}
+
// FilterCondition represents a single filter condition
type FilterCondition struct {
Type string `json:"type"` // "metric", "text", or "raw"
diff --git a/internal/alerts/config_runtime.go b/internal/alerts/config_runtime.go
index 82dc21c2f..c45f48752 100644
--- a/internal/alerts/config_runtime.go
+++ b/internal/alerts/config_runtime.go
@@ -42,6 +42,12 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
config.GuestDefaults.PoweredOffSeverity = alertconfig.NormalizePoweredOffSeverity(config.GuestDefaults.PoweredOffSeverity)
config.NodeDefaults.PoweredOffSeverity = alertconfig.NormalizePoweredOffSeverity(config.NodeDefaults.PoweredOffSeverity)
config.DockerIgnoredContainerPrefixes = alertconfig.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
+ config.Schedule.InitialNotify = alertconfig.NormalizeNotificationDeliveryTarget(config.Schedule.InitialNotify)
+ for index := range config.Schedule.Escalation.Levels {
+ config.Schedule.Escalation.Levels[index].Notify = alertconfig.NormalizeNotificationDeliveryTarget(
+ config.Schedule.Escalation.Levels[index].Notify,
+ )
+ }
// Migration logic for activation state (backward compatibility)
m.migrateActivationState(&config)
diff --git a/internal/alerts/default_config.go b/internal/alerts/default_config.go
index 5fb49a16d..1250f9973 100644
--- a/internal/alerts/default_config.go
+++ b/internal/alerts/default_config.go
@@ -163,6 +163,7 @@ func defaultAlertConfig() AlertConfig {
},
Cooldown: 5, // ON - 5 minutes prevents spam
MaxAlertsHour: 10, // ON - 10 alerts/hour prevents flooding
+ InitialNotify: "all",
NotifyOnResolve: true,
Escalation: EscalationConfig{
Enabled: false, // OFF - requires user configuration
diff --git a/internal/api/alerts.go b/internal/api/alerts.go
index 94b345075..4dbef8bdc 100644
--- a/internal/api/alerts.go
+++ b/internal/api/alerts.go
@@ -216,6 +216,7 @@ func (h *AlertHandlers) UpdateAlertConfig(w http.ResponseWriter, r *http.Request
updatedConfig.Schedule.Grouping.ByNode,
updatedConfig.Schedule.Grouping.ByGuest,
)
+ notificationMgr.SetInitialNotifyTarget(updatedConfig.Schedule.InitialNotify)
notificationMgr.SetNotifyOnResolve(updatedConfig.Schedule.NotifyOnResolve)
// Save to persistent storage. Failure here used to be swallowed (logged
diff --git a/internal/api/alerts_endpoints_test.go b/internal/api/alerts_endpoints_test.go
index c3596517f..d575dd908 100644
--- a/internal/api/alerts_endpoints_test.go
+++ b/internal/api/alerts_endpoints_test.go
@@ -34,7 +34,8 @@ func TestAlertsEndpoints(t *testing.T) {
t.Run("UpdateAlertConfig", func(t *testing.T) {
newConfig := alerts.AlertConfig{
Schedule: alerts.ScheduleConfig{
- Cooldown: 300,
+ Cooldown: 300,
+ InitialNotify: "apprise",
},
}
body, _ := json.Marshal(newConfig)
@@ -70,6 +71,12 @@ func TestAlertsEndpoints(t *testing.T) {
if updatedConfig.Schedule.Cooldown != 300 {
t.Errorf("expected cooldown 300, got %d", updatedConfig.Schedule.Cooldown)
}
+ if updatedConfig.Schedule.InitialNotify != "apprise" {
+ t.Errorf("expected initial notify apprise, got %q", updatedConfig.Schedule.InitialNotify)
+ }
+ if got := srv.monitor.GetNotificationManager().GetInitialNotifyTarget(); got != "apprise" {
+ t.Errorf("expected live initial target apprise, got %q", got)
+ }
})
t.Run("AlertIntentPolicies", func(t *testing.T) {
diff --git a/internal/config/persistence.go b/internal/config/persistence.go
index 556500eec..52085d4f3 100644
--- a/internal/config/persistence.go
+++ b/internal/config/persistence.go
@@ -95,6 +95,7 @@ type alertSchedulePresence struct {
type alertScheduleFieldPresence struct {
Cooldown *int `json:"cooldown"`
MaxAlertsHour *int `json:"maxAlertsHour"`
+ InitialNotify *string `json:"initialNotify"`
NotifyOnResolve *bool `json:"notifyOnResolve"`
Grouping *alertScheduleGroupingPresence `json:"grouping"`
}
@@ -979,6 +980,7 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
Schedule: alerts.ScheduleConfig{
Cooldown: 5,
MaxAlertsHour: 10,
+ InitialNotify: "all",
NotifyOnResolve: true,
Grouping: alerts.GroupingConfig{
Enabled: true,
@@ -1038,6 +1040,7 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
defaultSchedule := alerts.ScheduleConfig{
Cooldown: 5,
MaxAlertsHour: 10,
+ InitialNotify: "all",
NotifyOnResolve: true,
Grouping: alerts.GroupingConfig{
Enabled: true,
@@ -1056,6 +1059,9 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
if sched == nil || sched.MaxAlertsHour == nil {
config.Schedule.MaxAlertsHour = defaultSchedule.MaxAlertsHour
}
+ if sched == nil || sched.InitialNotify == nil {
+ config.Schedule.InitialNotify = defaultSchedule.InitialNotify
+ }
if sched == nil || sched.NotifyOnResolve == nil {
config.Schedule.NotifyOnResolve = defaultSchedule.NotifyOnResolve
}
diff --git a/internal/config/persistence_alerts_normalization_test.go b/internal/config/persistence_alerts_normalization_test.go
index 39064f3bc..71fb17917 100644
--- a/internal/config/persistence_alerts_normalization_test.go
+++ b/internal/config/persistence_alerts_normalization_test.go
@@ -28,6 +28,7 @@ func TestLoadAlertConfig_Normalization(t *testing.T) {
assert.True(t, cfg.Enabled)
assert.Equal(t, 5, cfg.Schedule.Cooldown)
assert.Equal(t, 10, cfg.Schedule.MaxAlertsHour)
+ assert.Equal(t, "all", cfg.Schedule.InitialNotify)
assert.True(t, cfg.Schedule.NotifyOnResolve)
assert.True(t, cfg.Schedule.Grouping.Enabled)
assert.Equal(t, 30, cfg.Schedule.Grouping.Window)
@@ -92,6 +93,26 @@ func TestLoadAlertConfig_Normalization(t *testing.T) {
assert.Equal(t, 5.0, cfg.HysteresisMargin)
},
},
+ {
+ name: "Schedule missing initialNotify defaults to all",
+ input: map[string]interface{}{
+ "schedule": map[string]interface{}{},
+ },
+ verify: func(t *testing.T, cfg *alerts.AlertConfig) {
+ assert.Equal(t, "all", cfg.Schedule.InitialNotify)
+ },
+ },
+ {
+ name: "Schedule explicit initialNotify is preserved",
+ input: map[string]interface{}{
+ "schedule": map[string]interface{}{
+ "initialNotify": "apprise",
+ },
+ },
+ verify: func(t *testing.T, cfg *alerts.AlertConfig) {
+ assert.Equal(t, "apprise", cfg.Schedule.InitialNotify)
+ },
+ },
{
name: "Schedule missing defaults notifyOnResolve to true",
input: map[string]interface{}{
diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go
index dba5f29b1..0deca90a2 100644
--- a/internal/monitoring/monitor.go
+++ b/internal/monitoring/monitor.go
@@ -1734,6 +1734,7 @@ func New(cfg *config.Config) (*Monitor, error) {
alertConfig.Schedule.Grouping.ByNode,
alertConfig.Schedule.Grouping.ByGuest,
)
+ m.notificationMgr.SetInitialNotifyTarget(alertConfig.Schedule.InitialNotify)
m.notificationMgr.SetNotifyOnResolve(alertConfig.Schedule.NotifyOnResolve)
} else {
log.Warn().Err(err).Msg("failed to load alert configuration")
diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go
index 9f93cb0c0..39476a68f 100644
--- a/internal/monitoring/monitor_host_agents_test.go
+++ b/internal/monitoring/monitor_host_agents_test.go
@@ -3051,6 +3051,40 @@ func TestApplyHostReportSkipsMetricsAndSMARTWritesInMockMode(t *testing.T) {
}
}
+func TestMonitorStartupAppliesPersistedInitialNotificationTarget(t *testing.T) {
+ previous := mock.IsMockEnabled()
+ mustSetMockEnabled(t, false)
+ t.Cleanup(func() { mustSetMockEnabled(t, previous) })
+
+ dataDir := t.TempDir()
+ persistence := config.NewConfigPersistence(dataDir)
+ alertManager := alerts.NewManagerWithDataDir(dataDir)
+ alertConfig := alertManager.GetConfig()
+ alertConfig.Enabled = true
+ alertConfig.ActivationState = alerts.ActivationActive
+ alertConfig.Schedule.InitialNotify = "email"
+ alertManager.UpdateConfig(alertConfig)
+ if err := persistence.SaveAlertConfig(alertManager.GetConfig()); err != nil {
+ alertManager.Stop()
+ t.Fatalf("save alert config: %v", err)
+ }
+ alertManager.Stop()
+
+ monitor, err := New(&config.Config{
+ ConfigPath: dataDir,
+ DataPath: dataDir,
+ EnvOverrides: make(map[string]bool),
+ })
+ if err != nil {
+ t.Fatalf("create monitor: %v", err)
+ }
+ t.Cleanup(monitor.Stop)
+
+ if actual := monitor.GetNotificationManager().GetInitialNotifyTarget(); actual != "email" {
+ t.Fatalf("startup initial notification target = %q, want email", actual)
+ }
+}
+
func waitForStoredDiskMetric(t *testing.T, store *metrics.Store, resourceID, metric string) []metrics.MetricPoint {
t.Helper()
diff --git a/internal/notifications/notification_delivery_target_test.go b/internal/notifications/notification_delivery_target_test.go
new file mode 100644
index 000000000..994816fae
--- /dev/null
+++ b/internal/notifications/notification_delivery_target_test.go
@@ -0,0 +1,148 @@
+package notifications
+
+import (
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/alerts"
+)
+
+func TestInitialNotifyTargetNormalization(t *testing.T) {
+ manager := NewNotificationManagerWithDataDir("", t.TempDir())
+ defer manager.Stop()
+
+ if actual := manager.GetInitialNotifyTarget(); actual != "all" {
+ t.Fatalf("default initial target = %q, want all", actual)
+ }
+
+ tests := map[string]string{
+ "EMAIL": "email",
+ " webhooks": "webhook",
+ "apprise": "apprise",
+ "invalid": "all",
+ }
+ for input, expected := range tests {
+ manager.SetInitialNotifyTarget(input)
+ if actual := manager.GetInitialNotifyTarget(); actual != expected {
+ t.Fatalf("initial target after %q = %q, want %q", input, actual, expected)
+ }
+ }
+}
+
+func TestNotificationDeliveryJobsRespectTarget(t *testing.T) {
+ email := EmailConfig{
+ Enabled: true,
+ SMTPHost: "smtp.example.test",
+ SMTPPort: 587,
+ From: "pulse@example.test",
+ To: []string{"ops@example.test"},
+ }
+ webhooks := []WebhookConfig{
+ {ID: "hook-1", Name: "First", URL: "https://hooks.example.test/one", Enabled: true},
+ {ID: "hook-2", Name: "Second", URL: "https://hooks.example.test/two", Enabled: true},
+ }
+ apprise := AppriseConfig{Enabled: true, Targets: []string{"ntfy://example.test/pulse"}}
+ alertList := []*alerts.Alert{{
+ ID: "node-1-cpu",
+ ResourceName: "node-1",
+ StartTime: time.Now(),
+ }}
+
+ tests := []struct {
+ name string
+ target notificationDeliveryTarget
+ expected []string
+ }{
+ {name: "all", target: notificationDeliveryTargetAll, expected: []string{"apprise", "email", "webhook", "webhook"}},
+ {name: "email", target: notificationDeliveryTargetEmail, expected: []string{"email"}},
+ {name: "webhook", target: notificationDeliveryTargetWebhook, expected: []string{"webhook", "webhook"}},
+ {name: "apprise", target: notificationDeliveryTargetApprise, expected: []string{"apprise"}},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ jobs := buildNotificationDeliveryJobsForTarget(
+ email,
+ webhooks,
+ apprise,
+ alertList,
+ eventAlert,
+ time.Time{},
+ test.target,
+ )
+ actual := make([]string, 0, len(jobs))
+ for _, job := range jobs {
+ actual = append(actual, job.Type)
+ }
+ sort.Strings(actual)
+ sort.Strings(test.expected)
+ if len(actual) != len(test.expected) {
+ t.Fatalf("job types = %#v, want %#v", actual, test.expected)
+ }
+ for index := range actual {
+ if actual[index] != test.expected[index] {
+ t.Fatalf("job types = %#v, want %#v", actual, test.expected)
+ }
+ }
+ })
+ }
+}
+
+func TestGroupedAlertsUseInitialNotifyTarget(t *testing.T) {
+ queue, err := NewNotificationQueue(t.TempDir())
+ if err != nil {
+ t.Fatalf("create queue: %v", err)
+ }
+ defer queue.Stop()
+
+ manager := &NotificationManager{
+ enabled: true,
+ initialTarget: notificationDeliveryTargetApprise,
+ emailConfig: EmailConfig{
+ Enabled: true,
+ SMTPHost: "smtp.example.test",
+ SMTPPort: 587,
+ From: "pulse@example.test",
+ To: []string{"ops@example.test"},
+ },
+ webhooks: []WebhookConfig{{
+ ID: "hook-1", Name: "Webhook", URL: "https://hooks.example.test/pulse", Enabled: true,
+ }},
+ appriseConfig: AppriseConfig{
+ Enabled: true,
+ Targets: []string{"ntfy://example.test/pulse"},
+ },
+ lastNotified: make(map[string]notificationRecord),
+ deliveryReceipts: make(map[string]struct{}),
+ queue: queue,
+ }
+ manager.pendingAlerts = []*alerts.Alert{{
+ ID: "node-1-cpu",
+ ResourceName: "node-1",
+ StartTime: time.Now(),
+ }}
+
+ manager.sendGroupedAlerts()
+
+ rows, err := queue.db.Query(`SELECT type FROM notification_queue ORDER BY created_at`)
+ if err != nil {
+ t.Fatalf("query queued types: %v", err)
+ }
+ defer rows.Close()
+
+ var queuedTypes []string
+ for rows.Next() {
+ var queuedType string
+ if err := rows.Scan(&queuedType); err != nil {
+ t.Fatalf("scan queued type: %v", err)
+ }
+ queuedTypes = append(queuedTypes, queuedType)
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("iterate queued types: %v", err)
+ }
+ if len(queuedTypes) != 1 || queuedTypes[0] != "apprise" {
+ t.Fatalf("queued types = %#v, want only apprise", queuedTypes)
+ }
+}
diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go
index 6713d2e45..bdd5124f0 100644
--- a/internal/notifications/notifications.go
+++ b/internal/notifications/notifications.go
@@ -236,6 +236,7 @@ type NotificationManager struct {
appriseConfig AppriseConfig
enabled bool
cooldown time.Duration
+ initialTarget notificationDeliveryTarget
notifyOnResolve bool
lastNotified map[string]notificationRecord
deliveryReceipts map[string]struct{}
@@ -711,6 +712,7 @@ func NewNotificationManagerWithDataDir(publicURL string, dataDir string) *Notifi
nm := &NotificationManager{
enabled: true,
cooldown: 5 * time.Minute,
+ initialTarget: notificationDeliveryTargetAll,
notifyOnResolve: true,
lastNotified: make(map[string]notificationRecord),
deliveryReceipts: make(map[string]struct{}),
@@ -883,6 +885,28 @@ func (n *NotificationManager) SetNotifyOnResolve(enabled bool) {
}
}
+// SetInitialNotifyTarget selects destinations for firing and grouped
+// notifications. Matching recovery delivery follows firing delivery receipts,
+// while escalation levels retain their own per-level routing.
+func (n *NotificationManager) SetInitialNotifyTarget(target string) {
+ normalized := normalizeNotificationDeliveryTarget(target)
+ n.mu.Lock()
+ was := n.initialTarget
+ n.initialTarget = normalized
+ n.mu.Unlock()
+
+ if was != normalized {
+ log.Info().Str("target", string(normalized)).Msg("updated initial alert notification target")
+ }
+}
+
+// GetInitialNotifyTarget returns the normalized initial delivery target.
+func (n *NotificationManager) GetInitialNotifyTarget() string {
+ n.mu.RLock()
+ defer n.mu.RUnlock()
+ return string(n.initialTarget)
+}
+
// GetNotifyOnResolve returns whether resolved alerts trigger notifications.
func (n *NotificationManager) GetNotifyOnResolve() bool {
n.mu.RLock()
@@ -1019,7 +1043,10 @@ func (n *NotificationManager) IsEnabled() bool {
// SendAlert sends notifications for a newly fired or explicitly re-notified alert.
func (n *NotificationManager) SendAlert(alert *alerts.Alert) {
- n.sendAlert(alert, alertSendOptions{target: notificationDeliveryTargetAll})
+ n.mu.RLock()
+ target := n.initialTarget
+ n.mu.RUnlock()
+ n.sendAlert(alert, alertSendOptions{target: target})
}
// SendEscalatedAlert sends a scheduled escalation notification. Escalation
@@ -1436,10 +1463,19 @@ func (n *NotificationManager) sendGroupedAlerts() {
emailConfig := copyEmailConfig(n.emailConfig)
webhooks := copyWebhookConfigs(n.webhooks)
appriseConfig := copyAppriseConfig(n.appriseConfig)
+ initialTarget := n.initialTarget
queue := n.queue
n.mu.Unlock()
- jobs := buildNotificationDeliveryJobs(emailConfig, webhooks, appriseConfig, alertsToSend, eventAlert, time.Time{})
+ jobs := buildNotificationDeliveryJobsForTarget(
+ emailConfig,
+ webhooks,
+ appriseConfig,
+ alertsToSend,
+ eventAlert,
+ time.Time{},
+ initialTarget,
+ )
if len(jobs) == 0 {
// Preserve cooldown semantics when notifications are globally enabled
// but no destination is configured. Delivery receipts remain empty, so